ds-bot 0.1.2 → 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.
@@ -44,6 +44,9 @@ const QUERY_TERMS_MAX = 6
44
44
  const SOURCE_READ_MS = 5_000
45
45
  const MODEL_TIMEOUT_MS = 120_000
46
46
  const SUMMARY_MAX_TOKENS = 2_048
47
+ // A reply cut off at the limit is tried once more with room for reasoning plus
48
+ // the answer; a model that thinks can spend the smaller budget thinking alone.
49
+ const SUMMARY_RETRY_MAX_TOKENS = 8_192
47
50
  const SUMMARY_TEXT_MAX = 8_000
48
51
  const ASK_TEXT_MAX = 2_000
49
52
  const ASK_CHANGES_MAX = 20
@@ -905,24 +908,32 @@ export function install(rt) {
905
908
  // the tag the meter would file them under "other".
906
909
  async function oneShot(route, system, text, tag) {
907
910
  const info = await ctx.llm.resolveModelInfo(route.provider, route.model).catch(() => undefined)
908
- const off = info?.reasoning?.efforts?.some(effort => effort.id === 'off')
909
- const assembler = new BlockAssembler()
911
+ const efforts = info?.reasoning?.efforts ?? []
912
+ // Thinking counts against the same output budget as the answer, so ask for
913
+ // the least the model offers: 'off' when it has one, else its lowest level.
914
+ const effort = efforts.some(entry => entry.id === 'off') ? 'off' : efforts.at(0)?.id
910
915
  const messages = [{ role: 'user', content: [{ type: 'text', text }] }]
911
916
  if (tag !== undefined) rt.tagUsage?.(messages, tag)
912
- for await (const chunk of ctx.llm.stream({
913
- provider: route.provider,
914
- model: route.model,
915
- ...(off ? { reasoningEffort: 'off' } : {}),
916
- system,
917
- messages,
918
- maxTokens: SUMMARY_MAX_TOKENS,
919
- signal: AbortSignal.timeout(MODEL_TIMEOUT_MS),
920
- })) assembler.push(chunk)
921
- const finish = assembler.finish
922
- if (finish.kind === 'error' || finish.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
923
- const reply = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
924
- if (reply === '') throw new Error('The model wrote nothing')
925
- return reply
917
+ for (const maxTokens of [SUMMARY_MAX_TOKENS, SUMMARY_RETRY_MAX_TOKENS]) {
918
+ const assembler = new BlockAssembler()
919
+ for await (const chunk of ctx.llm.stream({
920
+ provider: route.provider,
921
+ model: route.model,
922
+ ...(effort === undefined ? {} : { reasoningEffort: effort }),
923
+ system,
924
+ messages,
925
+ maxTokens,
926
+ signal: AbortSignal.timeout(MODEL_TIMEOUT_MS),
927
+ })) assembler.push(chunk)
928
+ const finish = assembler.finish
929
+ if (finish.kind === 'error' || finish.kind === 'aborted') throw new Error(finish.failure?.message ?? finish.kind)
930
+ const reply = assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('\n').trim()
931
+ if (finish.kind !== 'max-tokens' && reply !== '') return reply
932
+ // A reply cut off at the limit — all of it thinking, or a half-written
933
+ // answer — is dropped, never used; the retry gives it room once.
934
+ if (finish.kind !== 'max-tokens' && maxTokens === SUMMARY_MAX_TOKENS) throw new Error('The model wrote nothing')
935
+ }
936
+ throw new Error(`The model's reasoning used up the output budget (${SUMMARY_MAX_TOKENS} and ${SUMMARY_RETRY_MAX_TOKENS} tokens); no complete answer was written`)
926
937
  }
927
938
 
928
939
  /** @type {Map<string, {hash:string, text:string, at:number}>} */
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
@@ -608,6 +641,7 @@ export function install(rt) {
608
641
  async function forward(owner, messages) {
609
642
  const target = currentOf(owner)
610
643
  if (target === undefined || messages.length === 0) return
644
+ await rt.ensureCapabilities(target)
611
645
  const agent = await rt.resolveAgent(target)
612
646
  for (const message of messages) {
613
647
  const copy = createUserMessage({ content: message.content, ...(message.source ? { source: message.source } : {}) })
@@ -620,10 +654,10 @@ export function install(rt) {
620
654
 
621
655
  // `hard`: past the hard line or the window, the part moves on even without a note
622
656
  // from the model.
623
- function startSwitch(agent, { resume = false, carry = [], info, overflow = false, hard = overflow } = {}) {
657
+ function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, hard = overflow } = {}) {
624
658
  const place = placeOf(agent.id)
625
659
  if (place === undefined || switches.has(agent.id) || readOnly()) return
626
- const job = { ...place, carry: [...carry], info, resume, overflow, hard }
660
+ const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, hard }
627
661
  switches.set(agent.id, job)
628
662
  void runSwitch(agent, job)
629
663
  .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 +671,10 @@ export function install(rt) {
637
671
  // The stopped turn goes on where it was.
638
672
  const back = job.carry.splice(0)
639
673
  if (resume) {
674
+ const carried = job.joined?.length ? { joined: job.joined } : {}
640
675
  back.unshift(createUserMessage({
641
676
  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 },
677
+ source: job.info ? { ...job.info, ...carried } : { kind: SOURCE_KIND, form: 'relay', role: 'resume', exchangeId: randomUUID(), senderName: 'DS Bot', hop: 0, ...carried },
643
678
  }))
644
679
  }
645
680
  await forward(job, back).catch(error => rt.warn('ds-bot: input for %s was not handed on: %s', agent.id, error))
@@ -678,7 +713,7 @@ export function install(rt) {
678
713
  const number = parts.length
679
714
  let written
680
715
  let failure
681
- if (!job.overflow) {
716
+ if (!job.overflow && job.failed === undefined) {
682
717
  try {
683
718
  written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]))
684
719
  } catch (error) {
@@ -699,15 +734,16 @@ export function install(rt) {
699
734
  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
735
  const previous = parts.at(-2)
701
736
  const noted = written !== undefined ? { note: written.note }
702
- : { fallback: job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
737
+ : { fallback: job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
703
738
  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 })
739
+ const carried = job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : await handoffLines(agent.id, { budget, group: room !== undefined, number })
705
740
  if (signal.aborted) throw signal.reason
706
741
  const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name)
707
742
  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)
743
+ const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
744
+ if (ref !== undefined) {
745
+ const effort = route?.reasoningEffort ?? await rt.desiredEffort(bot, ref)
746
+ await rt.selectSessionModel(freshId, { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) })
711
747
  .catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error))
712
748
  }
713
749
  await rt.applyPermission(bot, freshId)
@@ -729,11 +765,13 @@ export function install(rt) {
729
765
  }
730
766
  }
731
767
  await rt.save()
768
+ if (job.failed !== undefined) rescued.set(freshId, job.bare ? 'bare' : 'carried')
732
769
  void rt.moveSchedules(agent.id, freshId)
733
770
  job.moved = true
734
771
  const resume = job.resume || job.overflow
735
- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow })
772
+ const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
736
773
  freshParts.set(freshId, estimateTokens(text))
774
+ await rt.ensureCapabilities(freshId)
737
775
  const fresh = await rt.resolveAgent(freshId)
738
776
  fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
739
777
  await ctx.sessions.flush(fresh.session)
@@ -781,10 +819,28 @@ export function install(rt) {
781
819
  // `messages` left the inbox for this step, so the switch carries them along.
782
820
  const entry = rt.liveOf(agent.id)
783
821
  entry.switched = true
784
- startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, hard: true })
822
+ startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, hard: true })
785
823
  return { kind: 'reject' }
786
824
  }, { prepend: true })
787
825
 
826
+ ctx.on('agent/request-error', async (payload, next) => {
827
+ const failure = payload.failure
828
+ if (!STUCK_CODES.includes(failure?.code)) return next()
829
+ const decided = await next()
830
+ if (decided !== undefined || payload.signal?.aborted) return decided
831
+ await rt.load()
832
+ const { agent } = payload
833
+ if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly() || rescued.get(agent.id) === 'bare') return decided
834
+ // A provider may turn down one request for reasons of its own; a history that
835
+ // breaks every request fails the next turn as well.
836
+ if (failure.code === 'INVALID_REQUEST' && !STUCK_CODES.includes(lastTurnEnd(agent)?.data?.reason?.error?.code)) return decided
837
+ const entry = rt.liveOf(agent.id)
838
+ entry.switched = true
839
+ // The latest messages carried into a rescued part may be what breaks the request.
840
+ startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true, failed: failure.code, bare: rescued.has(agent.id) })
841
+ return undefined
842
+ }, { prepend: true })
843
+
788
844
  ctx.on('agent/request-error', async (payload, next) => {
789
845
  if (payload.failure?.code !== OVERFLOW || payload.signal?.aborted) return next()
790
846
  await rt.load()
@@ -792,7 +848,7 @@ export function install(rt) {
792
848
  if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly()) return next()
793
849
  const entry = rt.liveOf(agent.id)
794
850
  entry.switched = true
795
- startSwitch(agent, { resume: true, info: entry.info, overflow: true })
851
+ startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true })
796
852
  return undefined
797
853
  }, { prepend: true })
798
854
 
@@ -819,8 +875,25 @@ export function install(rt) {
819
875
  }
820
876
  }
821
877
 
878
+ // The newest turn/end among the Session's last events in memory.
879
+ function lastTurnEnd(agent) {
880
+ const latest = agent.session.seq - 1
881
+ for (let seq = latest; seq >= Math.max(0, latest - 199); seq -= 1) {
882
+ const event = agent.session.eventAt?.(seq)
883
+ if (event?.type === 'turn/end') return event
884
+ }
885
+ return undefined
886
+ }
887
+
888
+ function releaseRescued(agent) {
889
+ if (!rescued.has(agent.id)) return
890
+ const end = lastTurnEnd(agent)
891
+ if (end !== undefined && end.data?.reason?.kind !== 'error') rescued.delete(agent.id)
892
+ }
893
+
822
894
  ctx.on('agent/status', ({ agent, status }) => {
823
895
  if (status === 'idle' && rt.isBotSession(agent.id)) {
896
+ releaseRescued(agent)
824
897
  void checkContext(agent).catch(error => rt.warn('ds-bot: context check for %s failed: %s', agent.id, error))
825
898
  }
826
899
  })
@@ -864,7 +937,7 @@ export function install(rt) {
864
937
  } else if (source?.kind === REPLY_KIND) {
865
938
  const reply = readReply(textOf(event.data.content))
866
939
  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)) {
940
+ } else if (source?.kind === SOURCE_KIND && source.handoff !== true && source.deferred !== true && ['request', 'reply', 'report', 'stopped'].includes(source.role)) {
868
941
  items.push({ kind: 'event', role: source.role, botId: source.senderSessionId ?? null, name: source.senderName ?? null, roomId: source.roomId ?? null, time: event.time })
869
942
  }
870
943
  }
@@ -5,14 +5,17 @@ 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.',
15
- '- 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.',
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. 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.',
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.',
18
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.',
@@ -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.',
@@ -0,0 +1,247 @@
1
+ // Resume: Bot work that DeepSeek Harness dropped when it closed or restarted. DSH closes
2
+ // a turn it never finished as interrupted when the Session next opens, and a clean
3
+ // shutdown also cancels the messages still waiting in a Session's inbox; nothing would
4
+ // ever run either again, and whoever waited for them was not told. DS Bot notes each
5
+ // running Bot turn and each message it delivers until a turn takes it up, in
6
+ // inflight.json; what is still noted at the next start is picked up once.
7
+ import { randomUUID } from 'node:crypto'
8
+ import { readFileSync } from 'node:fs'
9
+ import { mkdir, writeFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import { SOURCE_KIND } from './constants.js'
12
+
13
+ // Long enough for DSH to finish opening its workspaces after a start.
14
+ const RESUME_DELAY_MS = 3000
15
+ // A delivery waiting longer than this was not lost to a restart.
16
+ const QUEUED_KEPT_MS = 24 * 60 * 60 * 1000
17
+ const QUEUED_MAX = 50
18
+
19
+ export const resumeText = record => [
20
+ 'DeepSeek Harness restarted while you were working on this, so your last turn stopped before it finished.',
21
+ record.approval ? `You were waiting for the user's approval to run ${record.approval.tool || 'a tool'}${record.approval.reason ? ` (${record.approval.reason})` : ''}; that request is gone, so ask again if you still need it.` : '',
22
+ 'Check what is already done (files, commands, messages) before you repeat anything, then finish the task and give your reply.',
23
+ ].filter(Boolean).join(' ')
24
+
25
+ // The trigger fields a resumed turn needs to answer the same sender.
26
+ const triggerOf = info => ({
27
+ role: info.role,
28
+ ...(info.exchangeId ? { exchangeId: info.exchangeId } : {}),
29
+ ...(info.senderSessionId ? { senderSessionId: info.senderSessionId } : {}),
30
+ ...(info.roomId ? { roomId: info.roomId } : {}),
31
+ ...(typeof info.hop === 'number' ? { hop: info.hop } : {}),
32
+ ...(info.audience ? { audience: info.audience } : {}),
33
+ ...(info.askedBy ? { askedBy: info.askedBy } : {}),
34
+ ...(info.senderRoomId ? { senderRoomId: info.senderRoomId } : {}),
35
+ })
36
+
37
+ const readNotes = (file) => {
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
40
+ const object = value => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {})
41
+ return { turns: object(parsed?.turns), queued: object(parsed?.queued) }
42
+ } catch {
43
+ return { turns: {}, queued: {} }
44
+ }
45
+ }
46
+
47
+ export function install(rt) {
48
+ const { ctx, home } = rt
49
+ const file = join(home, 'inflight.json')
50
+
51
+ // What the last run left; read before this run notes anything.
52
+ let interrupted = readNotes(file)
53
+
54
+ /** @type {Record<string, {botId:string, roomId?:string, since:number, trigger?:any, approval?:{tool:string, reason:string}, resumed?:boolean}>} */
55
+ let turns = {}
56
+ /** @type {Record<string, {at:number, args:any}[]>} deliveries no turn has taken up, by Session */
57
+ let queued = {}
58
+ let writing = Promise.resolve()
59
+ let stopped = false
60
+ let latest
61
+ let due = false
62
+ const persist = () => {
63
+ // Only the host that saves the team runs its work again.
64
+ if (stopped || rt.readOnly() !== undefined) return
65
+ latest = JSON.stringify({ turns, queued })
66
+ // One write waits at a time and takes the latest notes, so a busy round does not
67
+ // write the file once per change.
68
+ if (due) return
69
+ due = true
70
+ // Written in place: a rename over the file fails on Windows while anything reads
71
+ // it, and a torn write only loses what one restart would pick up.
72
+ writing = writing.then(async () => {
73
+ due = false
74
+ const snapshot = latest
75
+ await mkdir(home, { recursive: true })
76
+ await writeFile(file, snapshot, 'utf8')
77
+ }).catch(error => rt.warn('ds-bot: noting running turns failed: %s', error))
78
+ }
79
+ const update = (id, change) => {
80
+ const next = change(turns[id])
81
+ if (next === turns[id]) return
82
+ turns = { ...turns }
83
+ if (next === undefined) delete turns[id]
84
+ else turns[id] = next
85
+ persist()
86
+ }
87
+ const forgetQueued = (exchangeId) => {
88
+ for (const [sessionId, list] of Object.entries(queued)) {
89
+ const at = list.findIndex(item => item.args.exchangeId === exchangeId)
90
+ if (at === -1) continue
91
+ const rest = list.toSpliced(at, 1)
92
+ queued = { ...queued }
93
+ if (rest.length === 0) delete queued[sessionId]
94
+ else queued[sessionId] = rest
95
+ persist()
96
+ return
97
+ }
98
+ }
99
+
100
+ const deliver = rt.deliver
101
+ rt.deliver = async (args) => {
102
+ const noted = { ...args, exchangeId: args.exchangeId ?? randomUUID() }
103
+ const sessionId = noted.sessionId ?? rt.chatOf(noted.toId)
104
+ // Noted before it is sent: a turn may take it up before the send returns.
105
+ queued = { ...queued, [sessionId]: [...(queued[sessionId] ?? []), { at: Date.now(), args: noted }].slice(-QUEUED_MAX) }
106
+ persist()
107
+ try {
108
+ return await deliver(noted)
109
+ } catch (error) {
110
+ forgetQueued(noted.exchangeId)
111
+ throw error
112
+ }
113
+ }
114
+
115
+ // A message is done with once a turn takes it up, or once it leaves the inbox untaken
116
+ // while DS Bot runs: the user stopped the Bot, or DS Bot moved or withdrew it.
117
+ ctx.on('agent/inbox/discarded', ({ message } = {}) => {
118
+ if (message?.source?.kind === SOURCE_KIND && message.source.exchangeId) forgetQueued(message.source.exchangeId)
119
+ })
120
+ // Exchanges a turn took up since this start, until the resends are done, so a resend
121
+ // never repeats one.
122
+ let taken = Object.keys(interrupted.queued).length > 0 ? new Set() : undefined
123
+ // The first turn event of each Session since this start, until the noted turns are
124
+ // picked up. A turn that ends, or still runs, before one starts here outlived the
125
+ // last run: only DS Bot restarted (the plugin reloaded) and DSH went on.
126
+ let firstTurn = Object.keys(interrupted.turns).length > 0 ? new Map() : undefined
127
+ ctx.on('session/event', (session, event) => {
128
+ const id = session?.id
129
+ if (id === undefined) return
130
+ if ((event.type === 'turn/start' || event.type === 'turn/end') && firstTurn?.has(id) === false) firstTurn.set(id, event.type)
131
+ if (event.type === 'user/message' && event.data?.source?.kind === SOURCE_KIND && event.data.source.exchangeId) {
132
+ taken?.add(event.data.source.exchangeId)
133
+ forgetQueued(event.data.source.exchangeId)
134
+ }
135
+ if (!rt.isBotSession(id)) return
136
+ switch (event.type) {
137
+ case 'turn/start': {
138
+ const owner = rt.groupOwners.get(id)
139
+ const botId = owner?.botId ?? rt.chatOwners.get(id)
140
+ update(id, () => ({ botId, ...(owner ? { roomId: owner.roomId } : {}), since: event.time ?? Date.now() }))
141
+ break
142
+ }
143
+ case 'user/message': {
144
+ if (event.data?.source?.kind !== SOURCE_KIND) break
145
+ // turns.js, installed earlier, has already claimed the turn's trigger.
146
+ const info = rt.live.get(id)?.info
147
+ update(id, record => (record === undefined || record.trigger !== undefined || info === undefined ? record
148
+ : { ...record, trigger: triggerOf(info), ...(info.resumed ? { resumed: true } : {}) }))
149
+ break
150
+ }
151
+ case 'turn/end': {
152
+ // DSH stops every turn as it shuts down; that one is picked up at the next start.
153
+ const reason = event.data?.reason
154
+ if (reason?.kind === 'aborted' && reason.reason?.kind === 'disposed') break
155
+ update(id, () => undefined)
156
+ break
157
+ }
158
+ }
159
+ })
160
+
161
+ // An approval the restart drops is named when the turn is picked up.
162
+ rt.noteApproval = (sessionId, approval) => update(sessionId, record => (record === undefined ? record
163
+ : approval === undefined ? (({ approval: _, ...rest }) => rest)(record) : { ...record, approval }))
164
+
165
+ async function resumeTurn(sessionId, record) {
166
+ const bot = rt.botOf(record.botId)
167
+ if (bot === undefined) return
168
+ const text = resumeText(record)
169
+ if (record.roomId !== undefined) {
170
+ const room = rt.roomOf(record.roomId)
171
+ if (room === undefined || !room.members.includes(bot.id)) return
172
+ await rt.postToRoom(room, bot, '', { resumed: true, quiet: true, ...(record.resumed ? { again: true } : {}) })
173
+ // A turn already picked up once and cut off again stays stopped: a restart loop
174
+ // must not run it forever.
175
+ if (record.resumed) return
176
+ await rt.deliver({ toId: bot.id, sessionId: room.sessions?.[bot.id] ?? sessionId, role: 'room', roomId: room.id, note: 'picking up after a restart', resumed: true, text, ...(record.trigger?.askedBy ? { askedBy: record.trigger.askedBy } : {}) })
177
+ return
178
+ }
179
+ if (record.resumed) return
180
+ const trigger = record.trigger
181
+ if ((trigger?.role === 'request' || trigger?.role === 'stopped') && trigger.senderSessionId && trigger.audience !== 'user' && rt.botOf(trigger.senderSessionId)) {
182
+ // The sender still waits for this turn's answer, so it goes on under the same exchange.
183
+ await rt.deliver({ fromId: trigger.senderSessionId, toId: bot.id, role: trigger.role, hop: trigger.hop ?? 0, ...(trigger.exchangeId ? { exchangeId: trigger.exchangeId } : {}), ...(trigger.senderRoomId ? { fromRoomId: trigger.senderRoomId } : {}), resumed: true, text })
184
+ return
185
+ }
186
+ await rt.deliver({ toId: bot.id, role: 'resume', resumed: true, text })
187
+ }
188
+
189
+ // A hard stop keeps the inbox, which the Session runs once it wakes; only what DSH
190
+ // cancelled is sent again.
191
+ async function resendQueued(sessionId, list) {
192
+ // A Session that moved on to a new part since is reached at its current one.
193
+ const current = (args) => {
194
+ if (rt.isBotSession(sessionId)) return sessionId
195
+ return args.roomId === undefined ? rt.chatOf(args.toId) : rt.roomOf(args.roomId)?.sessions?.[args.toId]
196
+ }
197
+ const fresh = list.filter(item => Date.now() - item.at < QUEUED_KEPT_MS && rt.botOf(item.args?.toId) !== undefined
198
+ && current(item.args) !== undefined && taken?.has(item.args.exchangeId) !== true)
199
+ if (fresh.length === 0) return
200
+ const target = current(fresh[0].args)
201
+ const agent = await rt.resolveAgent(target)
202
+ const waiting = new Set([...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])].map(message => message.source?.exchangeId))
203
+ for (const { args } of fresh) {
204
+ if (waiting.has(args.exchangeId) || taken?.has(args.exchangeId) === true) continue
205
+ await rt.deliver({ ...args, sessionId: current(args), steer: false, resent: true })
206
+ }
207
+ }
208
+
209
+ async function resumeAll() {
210
+ await rt.load()
211
+ if (rt.readOnly() !== undefined) return
212
+ const { turns: noted, queued: dropped } = interrupted
213
+ interrupted = { turns: {}, queued: {} }
214
+ // Taken up now, so another restart does not pick them up twice.
215
+ persist()
216
+ // Agents and inboxes are read before anything wakes their Sessions.
217
+ const cut = []
218
+ for (const [sessionId, record] of Object.entries(noted)) {
219
+ const first = firstTurn.get(sessionId)
220
+ if (first === 'turn/end') continue
221
+ // Still running: noted again, as if it had started here.
222
+ if (first === undefined && (await rt.resolveAgent(sessionId).catch(() => undefined))?.status === 'running') update(sessionId, () => record)
223
+ else cut.push([sessionId, record])
224
+ }
225
+ firstTurn = undefined
226
+ for (const [sessionId, list] of Object.entries(dropped)) {
227
+ if (!Array.isArray(list)) continue
228
+ await resendQueued(sessionId, list).catch(error => rt.warn('ds-bot: sending again what %s was sent failed: %s', sessionId, error))
229
+ }
230
+ taken = undefined
231
+ for (const [sessionId, record] of cut) {
232
+ await resumeTurn(sessionId, record).catch(error => rt.warn('ds-bot: picking up the turn of %s failed: %s', sessionId, error))
233
+ }
234
+ }
235
+
236
+ ctx.effect(() => {
237
+ const pending = Object.keys(interrupted.turns).length > 0 || Object.keys(interrupted.queued).length > 0
238
+ const timer = pending ? setTimeout(() => void resumeAll().catch(error => rt.warn('ds-bot: picking up interrupted work failed: %s', error)), RESUME_DELAY_MS) : undefined
239
+ timer?.unref?.()
240
+ // What the turns stopped by this shutdown left stays as last noted.
241
+ return () => {
242
+ stopped = true
243
+ clearTimeout(timer)
244
+ return writing
245
+ }
246
+ }, 'ds-bot: resume interrupted turns')
247
+ }