ds-bot 0.1.4 → 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/README.md +4 -3
- package/client.js +1558 -1296
- package/index.js +5 -3
- package/package.json +1 -1
- package/src/host/channel.js +9 -3
- package/src/host/constants.js +3 -0
- package/src/host/delivery.js +1 -0
- package/src/host/folders.js +135 -0
- package/src/host/groups.js +4 -3
- package/src/host/images.js +2 -2
- package/src/host/memory.js +31 -8
- package/src/host/parts.js +98 -32
- package/src/host/prompt.js +4 -4
- package/src/host/roster.js +40 -10
- package/src/host/tools.js +88 -16
- package/src/host/turns.js +33 -7
- package/src/host/usage.js +4 -1
package/src/host/parts.js
CHANGED
|
@@ -90,8 +90,9 @@ export function historyEntry(event, group) {
|
|
|
90
90
|
const text = textOf(event.data?.message?.content)
|
|
91
91
|
return text === '' ? undefined : { answer: true, text }
|
|
92
92
|
}
|
|
93
|
-
// Cards only show in an own chat.
|
|
94
|
-
|
|
93
|
+
// Cards only show in an own chat. A PTC program's sub-call asks too.
|
|
94
|
+
const asks = (event.type === 'tool/call' || event.type === 'tool/ptc-dispatch-start') && event.data?.name === 'ask_user'
|
|
95
|
+
if (asks && !group) {
|
|
95
96
|
const text = cardText(event.data.arguments)
|
|
96
97
|
return text === '' ? undefined : { answer: true, text }
|
|
97
98
|
}
|
|
@@ -143,6 +144,7 @@ export function install(rt) {
|
|
|
143
144
|
const STEWARD_MAX_TOKENS = 4_096
|
|
144
145
|
const STEWARD_TIMEOUT_MS = 120_000
|
|
145
146
|
const STEWARD_LINES = 40
|
|
147
|
+
const FOLDER_RETRY_MS = 300_000
|
|
146
148
|
|
|
147
149
|
// A broken provider adapter must not crash every turn; estimate from the
|
|
148
150
|
// derived messages instead, with one warning.
|
|
@@ -312,7 +314,8 @@ export function install(rt) {
|
|
|
312
314
|
complete = false
|
|
313
315
|
rt.warn('ds-bot: reading %s for its handoff stopped early: %s', sessionId, error)
|
|
314
316
|
}
|
|
315
|
-
|
|
317
|
+
// `whole`: every message of the part is in `recent`.
|
|
318
|
+
return { recent: recent.reverse(), earlier: earlier.reverse(), unlisted, complete, whole: complete && !full }
|
|
316
319
|
}
|
|
317
320
|
|
|
318
321
|
// The note's sections, in order. A note without all of them is not used.
|
|
@@ -326,8 +329,8 @@ export function install(rt) {
|
|
|
326
329
|
['Next step', 'the single next action, with the words of the request it follows quoted exactly; "(none)" when nothing is pending'],
|
|
327
330
|
['Where to look', 'details this note leaves out that you may need again, each with a few words copied exactly from the message that holds it, for read_own_chat to search'],
|
|
328
331
|
]
|
|
329
|
-
const
|
|
330
|
-
|
|
332
|
+
const handoffPrompt = why => [
|
|
333
|
+
`You are now writing a handoff note to yourself. This request comes from DS Bot, not from the user: do not answer or continue the messages above. ${why}, so it moves on to a fresh conversation now. The fresh one starts with this note and your latest messages word for word; everything else leaves your working context. The full record stays saved, and you can search it later with read_own_chat. Write the note that lets you carry on as the same Bot without losing anything that matters.`,
|
|
331
334
|
'',
|
|
332
335
|
'Output EXACTLY the Markdown structure below: every heading as written, in English and in this order. Write the content in the language the user writes in, as terse bullets. Write "(none)" under a heading with nothing to say.',
|
|
333
336
|
'',
|
|
@@ -341,6 +344,8 @@ export function install(rt) {
|
|
|
341
344
|
'- Do NOT mention this request or the move to a fresh conversation.',
|
|
342
345
|
'- Output only the note, in at most about 2,000 words: no preface, and no tool calls.',
|
|
343
346
|
].join('\n')
|
|
347
|
+
const HANDOFF_PROMPT = handoffPrompt('Your conversation is long')
|
|
348
|
+
const FOLDER_PROMPT = handoffPrompt('Your working folder changed')
|
|
344
349
|
const NOTE_RETRY_SHORTER = 'Your last note was cut off at the output limit, so it was not used. Write the whole note again, shorter: at most about 1,000 words. Keep every heading; drop detail before you drop facts.'
|
|
345
350
|
const noteRetryMissing = missing => `Your last note lacked these headings, so it was not used: ${missing.map(name => `"## ${name}"`).join(', ')}. Write the whole note again with every heading exactly as given, in English.`
|
|
346
351
|
const missingSections = note => NOTE_SECTIONS.map(([name]) => name)
|
|
@@ -349,16 +354,17 @@ export function install(rt) {
|
|
|
349
354
|
// The same system prompt, tools, and messages as the conversation's own requests,
|
|
350
355
|
// plus the note prompt; nothing is written into the Session. A cut-off note, or one
|
|
351
356
|
// without every section, is asked for again once; then the note fails.
|
|
352
|
-
async function writeNote(agent, signal) {
|
|
357
|
+
async function writeNote(agent, signal, { folder = false } = {}) {
|
|
353
358
|
const session = agent.session
|
|
354
359
|
const header = session.requestHeader()
|
|
355
360
|
const route = header?.config
|
|
356
361
|
if (!route?.provider || !route?.model) throw new Error('the Session has not called a model yet')
|
|
357
362
|
const messages = session.deriveMessages()
|
|
363
|
+
const base = folder ? FOLDER_PROMPT : HANDOFF_PROMPT
|
|
358
364
|
let retry
|
|
359
365
|
let problem
|
|
360
366
|
for (let attempt = 1; attempt <= NOTE_ATTEMPTS; attempt += 1) {
|
|
361
|
-
const prompt = retry === undefined ?
|
|
367
|
+
const prompt = retry === undefined ? base : `${base}\n\n${retry}`
|
|
362
368
|
const assembler = new BlockAssembler()
|
|
363
369
|
for await (const chunk of ctx.llm.stream({
|
|
364
370
|
provider: route.provider,
|
|
@@ -489,6 +495,7 @@ export function install(rt) {
|
|
|
489
495
|
system: STEWARD_SYSTEM,
|
|
490
496
|
messages: rt.tagUsage([{ role: 'user', content: [{ type: 'text', text: stewardRequest({ lines, midTurn, checkpoints: checkpointCount(session.deriveMessages()), group }) }] }], { sessionId: agent.id, kind: 'compaction' }),
|
|
491
497
|
maxTokens: STEWARD_MAX_TOKENS,
|
|
498
|
+
sessionId: session.id,
|
|
492
499
|
purpose: 'compaction',
|
|
493
500
|
signal: AbortSignal.timeout(STEWARD_TIMEOUT_MS),
|
|
494
501
|
})) assembler.push(chunk)
|
|
@@ -547,6 +554,10 @@ export function install(rt) {
|
|
|
547
554
|
// together without the model carries the last note the model wrote.
|
|
548
555
|
function noteText(segment) {
|
|
549
556
|
if (segment?.fallback === undefined) return segment?.note
|
|
557
|
+
if (segment.fallback === 'whole') {
|
|
558
|
+
const head = '(No new note: every message of the part was carried over word for word.'
|
|
559
|
+
return segment.note === undefined ? `${head})` : `${head} Below is the note written at the end of part ${segment.noteFrom}, carried over unchanged.)\n\n${segment.note}`
|
|
560
|
+
}
|
|
550
561
|
const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window'
|
|
551
562
|
: segment.fallback === 'failure' ? 'a request in the previous part kept failing'
|
|
552
563
|
: 'the model did not write a usable note'
|
|
@@ -556,13 +567,14 @@ export function install(rt) {
|
|
|
556
567
|
: `${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}`
|
|
557
568
|
}
|
|
558
569
|
|
|
559
|
-
function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare }) {
|
|
570
|
+
function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare, folder }) {
|
|
560
571
|
const where = room ? `your conversation in the group chat "${room.name}"` : 'your own chat with the user'
|
|
561
572
|
const lookup = room
|
|
562
573
|
? 'read_group_chat shows the group\'s latest messages, and read_own_chat your own chat.'
|
|
563
574
|
: '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
575
|
const reason = failed !== undefined ? `a request in the previous part kept failing (${failed})`
|
|
565
|
-
: overflow ? 'the previous part overflowed the model\'s window'
|
|
576
|
+
: overflow ? 'the previous part overflowed the model\'s window'
|
|
577
|
+
: folder !== undefined ? `your working folder changed to ${folder}` : 'the previous part grew long'
|
|
566
578
|
const { recent, earlier, unlisted, complete } = lines
|
|
567
579
|
const omitted = [
|
|
568
580
|
...(unlisted > 0 ? [`(${unlisted} older messages are not listed.)`] : []),
|
|
@@ -573,6 +585,7 @@ export function install(rt) {
|
|
|
573
585
|
`DS Bot moved ${where} on to a fresh conversation, part ${number}, because ${reason}. This message is not from the user.`,
|
|
574
586
|
'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.',
|
|
575
587
|
`Everything earlier stays saved: ${lookup}`,
|
|
588
|
+
...(folder !== undefined ? [`You now work in ${folder}: commands and file tools start there. Relative paths in the messages below meant the previous folder.`] : []),
|
|
576
589
|
...(failed !== undefined ? ['Images from the previous part are not in this conversation; if one matters, ask the user to send it again.'] : []),
|
|
577
590
|
'',
|
|
578
591
|
'## Handoff note',
|
|
@@ -607,8 +620,11 @@ export function install(rt) {
|
|
|
607
620
|
...(resume && job.joined?.length ? { joined: job.joined } : {}),
|
|
608
621
|
})
|
|
609
622
|
|
|
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}>} */
|
|
623
|
+
/** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, joined?:any[], resume:boolean, overflow:boolean, failed?:string, bare?:boolean, folder?:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
|
|
611
624
|
const switches = new Map()
|
|
625
|
+
// Parts whose move to their working folder failed, and the time before which it is
|
|
626
|
+
// not tried again.
|
|
627
|
+
const folderRetry = new Map()
|
|
612
628
|
// Parts opened because requests kept failing, until one of their turns ends well:
|
|
613
629
|
// 'carried' brought the latest messages along, 'bare' left them out.
|
|
614
630
|
/** @type {Map<string, 'carried'|'bare'>} */
|
|
@@ -653,11 +669,11 @@ export function install(rt) {
|
|
|
653
669
|
}
|
|
654
670
|
|
|
655
671
|
// `hard`: past the hard line or the window, the part moves on even without a note
|
|
656
|
-
// from the model.
|
|
657
|
-
function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, hard = overflow } = {}) {
|
|
672
|
+
// from the model. `folder`: the part moves on because its working folder changed.
|
|
673
|
+
function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, folder = false, hard = overflow || folder } = {}) {
|
|
658
674
|
const place = placeOf(agent.id)
|
|
659
675
|
if (place === undefined || switches.has(agent.id) || readOnly()) return
|
|
660
|
-
const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, hard }
|
|
676
|
+
const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, folder, hard }
|
|
661
677
|
switches.set(agent.id, job)
|
|
662
678
|
void runSwitch(agent, job)
|
|
663
679
|
.catch(error => rt.warn('ds-bot: %s did not move on to a fresh part: %s', rt.botOf(job.botId)?.name ?? agent.id, error))
|
|
@@ -665,8 +681,12 @@ export function install(rt) {
|
|
|
665
681
|
switches.delete(agent.id)
|
|
666
682
|
if (job.moved) {
|
|
667
683
|
noteFailures.delete(agent.id)
|
|
684
|
+
folderRetry.delete(agent.id)
|
|
685
|
+
// The folder may have changed again while the switch ran.
|
|
686
|
+
if (job.roomId === undefined) void followFolder(job.botId)
|
|
668
687
|
return
|
|
669
688
|
}
|
|
689
|
+
if (folder) folderRetry.set(agent.id, Date.now() + FOLDER_RETRY_MS)
|
|
670
690
|
if (!job.noteFailed) partBase.set(agent.id, tokensOf(agent.session) ?? 0)
|
|
671
691
|
// The stopped turn goes on where it was.
|
|
672
692
|
const back = job.carry.splice(0)
|
|
@@ -711,11 +731,16 @@ export function install(rt) {
|
|
|
711
731
|
const lines = await contextLines(session).catch(() => undefined)
|
|
712
732
|
const parts = partsOf(bot.id, room?.id)
|
|
713
733
|
const number = parts.length
|
|
734
|
+
const budget = Math.min(RECENT_TOKENS, Math.floor((lines?.check ?? 4 * RECENT_TOKENS) / 4))
|
|
735
|
+
const readLines = () => (job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : handoffLines(agent.id, { budget, group: room !== undefined, number }))
|
|
736
|
+
let carried = job.folder ? await readLines() : undefined
|
|
737
|
+
// A short part with no tool work goes over word for word, so it needs no note.
|
|
738
|
+
const whole = carried?.whole === true && !session.deriveMessages().some(message => message.role === 'tool')
|
|
714
739
|
let written
|
|
715
740
|
let failure
|
|
716
|
-
if (!job.overflow && job.failed === undefined) {
|
|
741
|
+
if (!job.overflow && job.failed === undefined && !whole) {
|
|
717
742
|
try {
|
|
718
|
-
written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]))
|
|
743
|
+
written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]), { folder: job.folder })
|
|
719
744
|
} catch (error) {
|
|
720
745
|
if (signal.aborted) throw error
|
|
721
746
|
failure = error
|
|
@@ -734,11 +759,12 @@ export function install(rt) {
|
|
|
734
759
|
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)
|
|
735
760
|
const previous = parts.at(-2)
|
|
736
761
|
const noted = written !== undefined ? { note: written.note }
|
|
737
|
-
: { fallback: job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
|
|
738
|
-
|
|
739
|
-
const carried = job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : await handoffLines(agent.id, { budget, group: room !== undefined, number })
|
|
762
|
+
: { fallback: whole ? 'whole' : job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
|
|
763
|
+
carried ??= await readLines()
|
|
740
764
|
if (signal.aborted) throw signal.reason
|
|
741
|
-
const
|
|
765
|
+
const dir = rt.workDirOf(job)
|
|
766
|
+
const intended = dir !== undefined && await rt.isFolder(dir) ? dir : rt.teamFolder()
|
|
767
|
+
const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name, { dir })
|
|
742
768
|
const route = session.requestHeader()?.config
|
|
743
769
|
const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
|
|
744
770
|
if (ref !== undefined) {
|
|
@@ -769,19 +795,26 @@ export function install(rt) {
|
|
|
769
795
|
void rt.moveSchedules(agent.id, freshId)
|
|
770
796
|
job.moved = true
|
|
771
797
|
const resume = job.resume || job.overflow
|
|
772
|
-
const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
|
|
773
|
-
freshParts.set(freshId, estimateTokens(text))
|
|
774
798
|
await rt.ensureCapabilities(freshId)
|
|
775
799
|
const fresh = await rt.resolveAgent(freshId)
|
|
800
|
+
const cwd = fresh.session.header?.cwd
|
|
801
|
+
const folder = cwd !== undefined && cwd !== session.header?.cwd ? cwd : undefined
|
|
802
|
+
// A fresh part that is not where it was made for would move on at every step.
|
|
803
|
+
if (intended !== undefined && cwd !== undefined && cwd !== intended) {
|
|
804
|
+
folderRetry.set(freshId, Date.now() + FOLDER_RETRY_MS)
|
|
805
|
+
rt.warn('ds-bot: the fresh part of %s did not start in %s; it works in %s', bot.name, intended, cwd)
|
|
806
|
+
}
|
|
807
|
+
const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare, folder })
|
|
808
|
+
freshParts.set(freshId, estimateTokens(text))
|
|
776
809
|
fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
|
|
777
810
|
await ctx.sessions.flush(fresh.session)
|
|
778
811
|
// Then what the old part held: the stopped turn's input, then whatever waited.
|
|
779
812
|
const held = [...agent.inbox.nextStep, ...agent.inbox.nextTurn]
|
|
780
813
|
for (const message of held) agent.inbox.remove(message.id)
|
|
781
814
|
await forward(job, [...job.carry.splice(0), ...held])
|
|
782
|
-
ctx.logger.info('ds-bot: %s%s moved on to part %d at %d tokens (check line %d, hard line %d); %s; %d latest and %d earlier messages carried', bot.name, room ? ` in ${room.name}` : '', number + 1, tokens ?? -1, lines?.check ?? -1, lines?.hard ?? -1,
|
|
815
|
+
ctx.logger.info('ds-bot: %s%s moved on to part %d at %d tokens (check line %d, hard line %d); %s; %d latest and %d earlier messages carried%s', bot.name, room ? ` in ${room.name}` : '', number + 1, tokens ?? -1, lines?.check ?? -1, lines?.hard ?? -1,
|
|
783
816
|
written !== undefined ? `note written in ${written.attempts} attempt(s)` : `note put together by DS Bot (${noted.fallback}${noted.note ? `, carries part ${noted.noteFrom}'s` : ', nothing to carry'})`,
|
|
784
|
-
carried.recent.length, carried.earlier.length)
|
|
817
|
+
carried.recent.length, carried.earlier.length, folder === undefined ? '' : `; now works in ${folder}`)
|
|
785
818
|
}
|
|
786
819
|
|
|
787
820
|
// Runs first: an earlier part hands its input on, a part past the hard line makes
|
|
@@ -807,21 +840,54 @@ export function install(rt) {
|
|
|
807
840
|
freshParts.delete(agent.id)
|
|
808
841
|
partBase.set(agent.id, (tokensOf(agent.session) ?? 0) + fresh)
|
|
809
842
|
}
|
|
843
|
+
if (!readOnly() && await folderMove(agent, { midTurn: payload.step > 1 }) !== undefined) return switchBeforeStep(agent, payload, { folder: true })
|
|
810
844
|
const lines = await contextLines(agent.session).catch(() => undefined)
|
|
811
845
|
const tokens = tokensOf(agent.session)
|
|
812
846
|
if (lines === undefined || tokens === undefined || tokens < lines.hard || !grown(agent.id, tokens, lines) || readOnly()) return next()
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
847
|
+
if (await decide(agent, { midTurn: payload.step > 1 }) === 'compact' && await condense(agent, lines, { inTurn: true, signal: payload.signal })) return next()
|
|
848
|
+
return switchBeforeStep(agent, payload, { hard: true })
|
|
849
|
+
}, { prepend: true })
|
|
850
|
+
|
|
851
|
+
// `messages` left the inbox for this step, so the switch carries them along.
|
|
852
|
+
function switchBeforeStep(agent, payload, options) {
|
|
853
|
+
// A switch may have started while the checks above awaited.
|
|
854
|
+
const running = switches.get(agent.id)
|
|
855
|
+
if (running !== undefined) {
|
|
856
|
+
running.carry.push(...payload.messages)
|
|
817
857
|
return { kind: 'reject' }
|
|
818
858
|
}
|
|
819
|
-
|
|
859
|
+
const midTurn = payload.step > 1
|
|
820
860
|
const entry = rt.liveOf(agent.id)
|
|
821
861
|
entry.switched = true
|
|
822
|
-
startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined,
|
|
862
|
+
startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, ...options })
|
|
823
863
|
return { kind: 'reject' }
|
|
824
|
-
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// The folder a part should move to, when it is not the one the part was made in. A
|
|
867
|
+
// working folder that is gone counts as the team workspace, where a fresh part would
|
|
868
|
+
// start. A group reply ends where it began; the group conversation moves at its next
|
|
869
|
+
// turn there.
|
|
870
|
+
async function folderMove(agent, { midTurn = false } = {}) {
|
|
871
|
+
const place = placeOf(agent.id)
|
|
872
|
+
const cwd = agent.session.header?.cwd
|
|
873
|
+
if (place === undefined || cwd === undefined || (midTurn && place.roomId !== undefined) || Date.now() < (folderRetry.get(agent.id) ?? 0)) return undefined
|
|
874
|
+
const wanted = rt.workDirOf(place)
|
|
875
|
+
const target = wanted !== undefined && await rt.isFolder(wanted) ? wanted : rt.teamFolder()
|
|
876
|
+
return target === undefined || target === cwd ? undefined : target
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// A Bot's own chat moves to its new folder now when it is idle; a running turn
|
|
880
|
+
// moves at its next step, and each group conversation at its next turn there.
|
|
881
|
+
async function followFolder(botId) {
|
|
882
|
+
const sessionId = rt.chatOf(botId)
|
|
883
|
+
if (sessionId === undefined || switches.has(sessionId) || readOnly()) return
|
|
884
|
+
try {
|
|
885
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
886
|
+
if (agent.status === 'idle' && await folderMove(agent) !== undefined) startSwitch(agent, { folder: true })
|
|
887
|
+
} catch (error) {
|
|
888
|
+
rt.warn('ds-bot: %s did not move to its working folder: %s', rt.botOf(botId)?.name ?? botId, error)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
825
891
|
|
|
826
892
|
ctx.on('agent/request-error', async (payload, next) => {
|
|
827
893
|
const failure = payload.failure
|
|
@@ -902,7 +968,7 @@ export function install(rt) {
|
|
|
902
968
|
// in the part's history. When nothing is left to do, that turn's reply is a fixed
|
|
903
969
|
// line instead of a model call.
|
|
904
970
|
ctx.on('llm/stream', (options, next) => {
|
|
905
|
-
if (options.sessionId === undefined || options.purpose !== undefined) return next()
|
|
971
|
+
if (options.sessionId === undefined || options.purpose !== undefined || rt.ownRequest(options)) return next()
|
|
906
972
|
const messages = options.messages ?? []
|
|
907
973
|
const since = messages.findLastIndex(message => message.role === 'assistant' || message.role === 'tool') + 1
|
|
908
974
|
const opened = messages.slice(since).filter(message => message.role === 'user' && (message.source?.kind === 'user' || message.source?.kind === SOURCE_KIND))
|
|
@@ -971,5 +1037,5 @@ export function install(rt) {
|
|
|
971
1037
|
}
|
|
972
1038
|
}
|
|
973
1039
|
|
|
974
|
-
Object.assign(rt, { clock, partsOf, historyEntry, partLines, noteText, earlierPage, switchJobs: () => switches.values() })
|
|
1040
|
+
Object.assign(rt, { clock, partsOf, historyEntry, partLines, noteText, earlierPage, followFolder, switchJobs: () => switches.values() })
|
|
975
1041
|
}
|
package/src/host/prompt.js
CHANGED
|
@@ -13,8 +13,8 @@ const TEAM_RULES = [
|
|
|
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
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
15
|
'- For "[Group chat …]", "[Message from …]", or "[Reply from …]" messages, do not write plans or progress updates: use tools silently, then reply with the answer. Simple answers do not need progress messages.',
|
|
16
|
-
'- 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.
|
|
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.',
|
|
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.',
|
|
17
|
+
'- update_bot changes your own settings: name, role label, color, model, working folder, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up, for example to work in a project folder (work_dir, a full path). Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.',
|
|
18
18
|
'- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. Any member can hand work to another with @Name, and the one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.',
|
|
19
19
|
'- When you need a key, token, or password, call request_secret: the user types it into a secure card and you never see it. Never ask the user to paste one into the chat. Use it through its environment variable ($DSH_SECRET_<NAME> in bash, $env:DSH_SECRET_<NAME> in PowerShell) and never print it. list_secrets shows which keys exist.',
|
|
20
20
|
'- web_search results say which search backend answered. If web_search fails, never guess URLs or present pages you remember as search results: use a browser tool if you have one, otherwise tell the user that search is unavailable and the reason it gave.',
|
|
@@ -30,8 +30,8 @@ const MAIN_RULES = [
|
|
|
30
30
|
'- Route work to the right Bot with message_bot, then tell the user what you asked and that you will report back.',
|
|
31
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.',
|
|
32
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.',
|
|
33
|
-
'- Use update_bot to rename a Bot, change its role, color, model, or instructions when the user asks.',
|
|
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
|
|
33
|
+
'- Use update_bot to rename a Bot, change its role, color, model, working folder, or instructions when the user asks.',
|
|
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, reply mode, or the folder every member works in there; 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.',
|
|
35
35
|
'- The team can have more than one Main Bot; list_bots marks them. They share these powers, so coordinate with them through message_bot.',
|
|
36
36
|
].join('\n')
|
|
37
37
|
|
package/src/host/roster.js
CHANGED
|
@@ -21,13 +21,16 @@ export function install(rt) {
|
|
|
21
21
|
return workspace.id
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// `dir` is a working folder outside the team workspace. Such a Session is made by cwd
|
|
25
|
+
// alone, so it joins no DSH workspace and the user's own project lists stay as they are.
|
|
26
|
+
async function createSession(title, { dir } = {}) {
|
|
25
27
|
// A Bot's tools run here too; while another dsh holds the team, a new Session would
|
|
26
28
|
// belong to no saved Bot.
|
|
27
29
|
const readOnly = rt.readOnly()
|
|
28
30
|
if (readOnly !== undefined) throw new Error(readOnly)
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
+
const usable = dir !== undefined && await rt.isFolder(dir)
|
|
32
|
+
if (dir !== undefined && !usable) rt.warn('ds-bot: the working folder %s is gone; %s starts in the team workspace', dir, title)
|
|
33
|
+
const { sessionId } = await ctx.sessionController.create(usable ? { cwd: dir } : { workspaceId: await ensureWorkspace() })
|
|
31
34
|
const agent = await rt.resolveAgent(sessionId)
|
|
32
35
|
await ctx.sessionTitle.rename(agent.session, title)
|
|
33
36
|
return sessionId
|
|
@@ -55,7 +58,7 @@ export function install(rt) {
|
|
|
55
58
|
|
|
56
59
|
// `pickModel` leaves the model to the user: the chat opens on a model card, and the
|
|
57
60
|
// greeting waits until a model is chosen. So does a Bot made when no model is served.
|
|
58
|
-
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
|
|
61
|
+
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, copyOf, pickModel = false }) {
|
|
59
62
|
const cleanName = String(botName ?? '').trim().slice(0, 40)
|
|
60
63
|
const problem = nameProblem(cleanName)
|
|
61
64
|
if (problem !== undefined) throw new Error(problem)
|
|
@@ -64,7 +67,8 @@ export function install(rt) {
|
|
|
64
67
|
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
|
|
65
68
|
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
|
|
66
69
|
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
|
|
67
|
-
const
|
|
70
|
+
const dir = workDir === undefined || workDir === null ? undefined : await rt.cleanWorkDir(workDir)
|
|
71
|
+
const id = await createSession(cleanName, { dir })
|
|
68
72
|
try {
|
|
69
73
|
state.bots[id] = {
|
|
70
74
|
id,
|
|
@@ -75,6 +79,7 @@ export function install(rt) {
|
|
|
75
79
|
...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
|
|
76
80
|
...(modelRef ? { model: modelRef } : {}),
|
|
77
81
|
...(effortId ? { effort: effortId } : {}),
|
|
82
|
+
...(dir ? { workDir: dir } : {}),
|
|
78
83
|
instructions: String(instructions).trim(),
|
|
79
84
|
createdAt: Date.now(),
|
|
80
85
|
createdBy: createdBy ?? null,
|
|
@@ -84,16 +89,19 @@ export function install(rt) {
|
|
|
84
89
|
const creator = rt.botOf(createdBy)
|
|
85
90
|
const original = rt.botOf(copyOf)
|
|
86
91
|
const language = rt.uiLanguage?.()
|
|
92
|
+
// The kickoff arrives as a user message, so it outranks the Bot's instructions
|
|
93
|
+
// unless it gives way to them, and it names no tool those instructions may rule out.
|
|
87
94
|
const kickoff = main
|
|
88
|
-
? `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
|
|
95
|
+
? `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 ask in one short question what they want the team to help with first. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
|
|
89
96
|
: original
|
|
90
|
-
? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then
|
|
97
|
+
? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then ask in one short question what this copy should focus on.`
|
|
91
98
|
: creator
|
|
92
|
-
? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then
|
|
99
|
+
? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then ask in one short question where to start. Keep it brief.`
|
|
93
100
|
: brief
|
|
94
101
|
? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
|
|
95
|
-
: 'The user just created you. Greet the user in one short message, then
|
|
96
|
-
const
|
|
102
|
+
: 'The user just created you. Greet the user in one short message, then ask in one short question what you should focus on.'
|
|
103
|
+
const opener = `${kickoff}\nWhere your instructions say otherwise, follow them.`
|
|
104
|
+
const text = !main && language ? `${opener}\nWrite in ${language}.` : opener
|
|
97
105
|
const bot = state.bots[id]
|
|
98
106
|
if (modelRef === undefined) {
|
|
99
107
|
bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
|
|
@@ -145,6 +153,8 @@ export function install(rt) {
|
|
|
145
153
|
avatar: bot.avatar,
|
|
146
154
|
model: served ? bot.model : undefined,
|
|
147
155
|
effort: served ? bot.effort : undefined,
|
|
156
|
+
// A folder that went away since leaves the copy in the team workspace.
|
|
157
|
+
workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined,
|
|
148
158
|
copyOf: bot.id,
|
|
149
159
|
})
|
|
150
160
|
}
|
|
@@ -152,6 +162,9 @@ export function install(rt) {
|
|
|
152
162
|
async function updateBot(id, patch) {
|
|
153
163
|
const bot = rt.botOf(id)
|
|
154
164
|
if (bot === undefined) throw new Error('Unknown Bot')
|
|
165
|
+
// Checked first, so a folder that does not pass leaves the rest unchanged too.
|
|
166
|
+
const moving = patch.workDir !== undefined
|
|
167
|
+
const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
|
|
155
168
|
if (patch.name !== undefined) {
|
|
156
169
|
const next = String(patch.name).trim().slice(0, 40)
|
|
157
170
|
const clash = rt.findBot(next)
|
|
@@ -191,8 +204,14 @@ export function install(rt) {
|
|
|
191
204
|
modelChanged = true
|
|
192
205
|
}
|
|
193
206
|
if (patch.permission !== undefined) await rt.setPermission(bot, patch.permission)
|
|
207
|
+
const moved = moving && workDir !== bot.workDir
|
|
208
|
+
if (moving) {
|
|
209
|
+
if (workDir === undefined) delete bot.workDir
|
|
210
|
+
else bot.workDir = workDir
|
|
211
|
+
}
|
|
194
212
|
await rt.save()
|
|
195
213
|
if (modelChanged) await rt.applyModel(bot, { force: true })
|
|
214
|
+
if (moved) void rt.followFolder(bot.id)
|
|
196
215
|
if (bot.model && bot.kickoff !== undefined) {
|
|
197
216
|
const held = bot.kickoff
|
|
198
217
|
delete bot.kickoff
|
|
@@ -260,6 +279,8 @@ export function install(rt) {
|
|
|
260
279
|
if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
|
|
261
280
|
if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
262
281
|
const title = custom || autoRoomName(members)
|
|
282
|
+
// The group's own Session only relays, so it stays in the team workspace; the
|
|
283
|
+
// group's folder is for its members' Sessions.
|
|
263
284
|
const id = await createSession(title)
|
|
264
285
|
try {
|
|
265
286
|
state.rooms[id] = {
|
|
@@ -309,6 +330,9 @@ export function install(rt) {
|
|
|
309
330
|
if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
310
331
|
const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
|
|
311
332
|
const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
|
|
333
|
+
const moving = patch.workDir !== undefined
|
|
334
|
+
if (moving && byAdmin) throw new Error(`Only the user or a Main Bot can set the folder of "${room.name}"`)
|
|
335
|
+
const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
|
|
312
336
|
|
|
313
337
|
const changes = []
|
|
314
338
|
const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
|
|
@@ -332,6 +356,12 @@ export function install(rt) {
|
|
|
332
356
|
room.notice = notice
|
|
333
357
|
changes.push(notice ? 'updated the notice' : 'cleared the notice')
|
|
334
358
|
}
|
|
359
|
+
// Members' group conversations move on to the folder at their next turn there.
|
|
360
|
+
if (moving && workDir !== room.workDir) {
|
|
361
|
+
if (workDir === undefined) delete room.workDir
|
|
362
|
+
else room.workDir = workDir
|
|
363
|
+
changes.push(workDir ? `every member now works in ${workDir} here` : 'every member works in its own folder again')
|
|
364
|
+
}
|
|
335
365
|
const before = room.name
|
|
336
366
|
if (name !== undefined) {
|
|
337
367
|
if (name !== '') {
|