diff --git a/changelog.d/894-room-standing-rules.changed.md b/changelog.d/894-room-standing-rules.changed.md new file mode 100644 index 00000000..86416cfa --- /dev/null +++ b/changelog.d/894-room-standing-rules.changed.md @@ -0,0 +1 @@ +Group chats use fewer tokens per turn: a member's group conversation now reads the group's notice, members and rules from its standing instructions instead of receiving them again with every turn, and a turn after a notice edit says only that the notice changed ([#894](https://github.com/ThinkFlowLab/FlyBot/issues/894)). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index c298f044..0cd0a38d 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -16,7 +16,7 @@ // it started, or that is called while still at earlier work, lets the round go on. Its // turn keeps running in its group Session, and what it ends with is posted to the group, // where the member that asked for it answers. -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { defineTool } from './tool-schema.js' import { ROOM_LATE_ROUNDS, SOURCE_KIND } from './constants.js' @@ -28,7 +28,7 @@ import { failureLine } from './turns.js' import { currentParticipants, isFaceRoom } from './room-membership.js' import { FACE_LEAD_NOTE, FACE_LEAD_ROUTE, faceLeadIsRemote, faceMemberNote, faceRoster, isFaceLead } from './face-lead.js' import { faceMentions } from './face-mentions.js' -import { stripSessionChips } from './delivery.js' +import { cleanName, stripSessionChips } from './delivery.js' import { createDshRoomParticipant } from './room-participant.js' const ROOM_LOG_MAX = 60 @@ -156,9 +156,11 @@ export const referenceNote = references => (references?.length : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' +// The turn's own closing line: the full reply rule above stands in the group's section of the system prompt. +const ROOM_REPLY_NOW = 'Reply now with your message for the group, no name prefix, or [PASS] to stay silent.' // Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i -const ROOM_REPLY_FORCED = 'Reply with your message for the group, no name prefix, short. Do not reply [PASS].' +const ROOM_REPLY_FORCED = 'This turn is the exception to the [PASS] rule: reply with your message for the group, no name prefix, short. Do not reply [PASS].' const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { @@ -181,6 +183,11 @@ const adminNote = room => isFaceRoom(room) ? FACE_LEAD_NOTE : [ ].filter(Boolean).join(' ') const ROOM_READ_MAX = 30 +// The group's standing section sits after DSH's own sections, so a member's group Session shares the +// longest possible system-prompt prefix with its own chat, and before the persona suffix that closes it. +const ROOM_SECTION_ORDER = 10150 +// What a member last saw of the notice, so its next turn says when the notice changed. +const noticeMark = notice => createHash('sha256').update(String(notice ?? '')).digest('hex').slice(0, 16) export function install(rt) { const { ctx } = rt @@ -214,37 +221,92 @@ export function install(rt) { return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } } - // A plain line in a face-to-face room (#299): the Bots decide who answers. Each gets this note, so one - // Bot answers a specific question, a follow-up goes to the Bot that just spoke, and a line to everyone may - // get a short answer from each. A pass posts nothing. + // A plain line in a face-to-face room (#299): the Bots decide who answers. The rule for such lines is + // in the room's standing section (routeRules); each routed turn carries this short note with what + // only this line can say, so one Bot answers a specific question, a follow-up goes to the Bot that + // just spoke, and a line to everyone may get a short answer from each. A pass posts nothing. function routeNote(room, member, round) { const { broadcast, last, forced } = round.route if (forced === member.id) return 'The user asked this and nobody else answered. Answer now: if the line is plainly for another owner\'s Bot, say that in one short sentence.' - const everyone = room.members.map(rt.botOf).filter(Boolean) - const roles = everyone.filter(bot => bot.role).map(bot => `${bot.name}${bot.id === member.id ? ' (you)' : ''}: ${String(bot.role).slice(0, 80)}`) - const friends = currentParticipants(room).filter(p => p.kind === 'bot' && p.remote).map(p => p.label || p.displayName).filter(Boolean) const answered = friendAnswers(room, round) return [ - 'This line names no Bot, and the group should get the right answer, not one from everyone. Decide whether it is yours.', broadcast - ? 'It is addressed to everyone (introductions, what each of you can do, a round of opinions): give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.' + ? 'This line names no Bot and is addressed to everyone: give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.' : isFaceLead(room, member) - ? FACE_LEAD_ROUTE - : 'Default to [PASS]: exactly one Bot answers a line like this, and the first Bot that answers ends it, so the Bots after it are not asked. Answer only if it is for you: it names you or your role, no other member\'s role fits it better, or it follows up on your own last answer. Reply [PASS] when it is for another Bot by name or role, when it follows up on another Bot\'s answer, or when you would only be guessing.', + ? 'This line names no Bot. You are the room lead, so a line like this is yours: answer it, by the rule for such lines in your instructions for this group.' + : 'This line names no Bot. Default to [PASS]: exactly one Bot answers it; answer only if it is for you, by the rule for such lines in your instructions for this group.', last && !broadcast ? `${last.name} spoke last before this line: a follow-up to what ${last.name} said ("what did you mean", "and then?", "say more") is for ${last.name}.` : '', - roles.length > 0 ? `What members do: ${roles.join('; ')}.` : '', - friends.length > 0 ? `Other owners' Bots here answer for themselves in their own home: ${friends.join(', ')}.` : '', answered.length > 0 ? `${answered.join(', ')} already answered this line or posted a plan; add only your role's part, and do not re-plan or repeat it.` : '', + ].filter(Boolean).join(' ') + } + // The standing rule for a face room's lines that name no Bot (#299, #476): the member's own default, + // the exception for a line to everyone, and the Bots that answer in their own home. + function routeRules(room, member) { + if (!isFaceRoom(room)) return '' + const friends = currentParticipants(room).filter(p => p.kind === 'bot' && p.remote).map(p => cleanName(p.label || p.displayName, '')).filter(Boolean) + return [ + 'A line from a person that names no Bot: the group should get the right answer, not one from everyone, so each Bot decides whether it is its own.', + isFaceLead(room, member) + ? FACE_LEAD_ROUTE + : 'Default to [PASS]: exactly one Bot answers a line like this, and the first Bot that answers ends it, so the Bots after it are not asked. Answer only if it is for you: it names you or your role, no other member\'s role fits it better (each Bot\'s role is in the list above), or it follows up on your own last answer. Reply [PASS] when it is for another Bot by name or role, when it follows up on another Bot\'s answer, or when you would only be guessing.', + 'A line addressed to everyone (introductions, what each of you can do, a round of opinions) is the exception: give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.', + friends.length > 0 ? `Other owners' Bots here answer for themselves in their own home: ${friends.join(', ')}.` : '', '[PASS] posts nothing and nobody sees it: never explain a pass.', ].filter(Boolean).join(' ') } - // Lines from before the round's opening message are kept apart from this round, so - // an earlier round (an old count-off, say) never reads as part of the current one. - function roomPrompt(room, member, round) { + // What holds for every turn of a member in this group (#894): who is here, how calls work, the + // notice, the member's own role here and the reply rule. It is a section of the system prompt, so + // the model reads it on every call and a checkpoint or a new part never drops it, while the + // Session's history keeps one copy per change instead of one per turn. Only room settings go in + // it, never the round, so it changes when the group does. Peer names and roles in a face roster + // are cleaned by faceRoster (one line each, capped, no markup). + function standingText(room, member) { const peers = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${rt.isAdminOf(room, bot.id) ? ' (admin)' : ''}`) const leads = rt.isAdminOf(room, member.id) + const members = `${isFaceRoom(room) ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` + return [ + '# This group chat', + 'This conversation is where you speak in one group chat. Each turn arrives as a "[Group chat …]" message with what is new and the speaking order; the rules below hold for every turn here.', + members, + room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '', + leads ? adminNote(room) : faceMemberNote(room, member, rt.botOf), + routeRules(room, member), + ROOM_REPLY, + ].filter(Boolean).join('\n\n') + } + const roomSectionFor = (sessionId) => { + const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId) + const room = rt.roomOf(owner?.roomId) + const member = rt.botOf(owner?.botId) + if (room === undefined || member === undefined || !room.members.includes(member.id)) return '' + return standingText(room, member) + } + ctx.effect(() => ctx.systemPrompt.section({ + name: 'bot-room', + order: ROOM_SECTION_ORDER, + interpolate: false, + text: context => roomSectionFor(context?.agent?.id), + }), 'ds-bot: group chat section') + + // A notice that changed since this member's last turn here: the section already holds the new + // text, so the turn only says that it changed. Marks the notice seen. + function noticeChange(room, member) { + room.noticeSeen ??= {} + const before = room.noticeSeen[member.id] + const now = noticeMark(room.notice) + room.noticeSeen[member.id] = now + if (before === undefined || before === now) return '' + return room.notice + ? 'The group notice changed since your last turn here; the new notice is in your instructions for this group.' + : 'The group notice was cleared since your last turn here.' + } + + // Lines from before the round's opening message are kept apart from this round, so + // an earlier round (an old count-off, say) never reads as part of the current one. + // The group's standing rules are not repeated here: they are in the section above. + function roomPrompt(room, member, round) { const lines = unseenLines(room, member.id) const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) const current = lines.filter(line => line.seq >= round.startSeq) @@ -252,13 +314,9 @@ export function install(rt) { const index = round.slots.findIndex(slot => slot.status === 'speaking') const before = index const after = round.slots.length - index - 1 - const members = `${isFaceRoom(room) ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` - const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '' const working = workingIn(room).filter(item => item.bot.id !== member.id) return [ - members, - notice, - leads ? adminNote(room) : faceMemberNote(room, member, rt.botOf), + noticeChange(room, member), earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', current.length ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}` @@ -268,7 +326,7 @@ export function install(rt) { `Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`, `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`, round.route ? routeNote(room, member, round) : '', - round.route?.forced === member.id ? ROOM_REPLY_FORCED : ROOM_REPLY, + round.route?.forced === member.id ? ROOM_REPLY_FORCED : ROOM_REPLY_NOW, ].filter(Boolean).join('\n\n') } diff --git a/core/test/context-sharing.test.js b/core/test/context-sharing.test.js index 39cc4b80..4c483f3a 100644 --- a/core/test/context-sharing.test.js +++ b/core/test/context-sharing.test.js @@ -126,7 +126,8 @@ async function start(t, config = {}) { if (memberIds().length >= expected && memberIds().every(id => roundTurns.has(id))) break await new Promise(resolve => setImmediate(resolve)) } - return memberIds().map(id => textOf(agents.get(id).calls[0])) + // The group's standing rules (members, notice) ride in its system-prompt section, the turn in the message. + return memberIds().map(id => `${ctx.sections.get('bot-room')?.text({ agent: { id } }) ?? ''}\n${textOf(agents.get(id).calls[0])}`) } return { ctx, call, emit, tool, agentOf, signIn, home, runRound } } diff --git a/core/test/install.test.js b/core/test/install.test.js index 80f70848..8b97b87e 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -61,6 +61,8 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'tool attach_capability', 'tool detach_capability', 'effect ds-bot: chat index', + 'effect ds-bot: group chat section', + 'prompt bot-room 10150', 'tool group_relay', 'tool read_own_chat', 'tool read_group_chat', diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 6ca8c7f3..e36ba410 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -114,6 +114,8 @@ const waitFor = async (check) => { return undefined } const turnFor = (sent, ...earlier) => waitFor(() => sent.find(entry => !earlier.includes(entry) && entry.message.source.role === 'room')) +// The group's standing rules for a member: its system-prompt section, read the way DSH renders it per call. +const standingOf = (ctx, sessionId) => ctx.sections.get('bot-room').text({ agent: { id: sessionId } }) test('a new group Session waits for capabilities before its first member prompt', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() @@ -749,8 +751,9 @@ test('#299: a plain line lets the Bots decide who answers: the passes post nothi const first = await turnFor(sent) assert.match(promptOf(first), /names no Bot/, 'the line is routed: each Bot decides whether it is theirs') assert.match(promptOf(first), /\[PASS\]/) - assert.match(promptOf(first), /Writer: Writes docs and copy/, 'members see what each does') - assert.match(promptOf(first), /Bob's Chief|Chief/, 'friend\'s Bots are named as answering for themselves') + // The roster with each Bot's role and the standing rule for such lines are the room's section (#894). + assert.match(standingOf(env.ctx, first.sessionId), /Writer: Writes docs and copy/, 'members see what each does') + assert.match(standingOf(env.ctx, first.sessionId), /Other owners' Bots here answer for themselves in their own home: .*Chief/, 'friend\'s Bots are named as answering for themselves') await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) const second = await turnFor(sent, first) assert.ok(second) @@ -893,7 +896,7 @@ test('#299: the pass rule is explicit, and the follow-up rule holds on the real const { running: one } = await say('文档怎么写', 'real-1') const a = await turnFor(sent) assert.match(promptOf(a), /Default to \[PASS\]/) - assert.match(promptOf(a), /the first Bot that answers ends it/) + assert.match(standingOf(env.ctx, a.sessionId), /the first Bot that answers ends it/) await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) const b = await turnFor(sent, a) assert.equal(b.sessionId, hosted.sessions[writer]) @@ -1123,7 +1126,7 @@ test('#455: another person\'s line and another home\'s Bot line that arrive mid- }) test('#476: the face room lead plans and assigns; other Bots do their part; the lead moves with the room in the next round', async (t) => { - const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() + const { call, emit, userMessage, relay, answer, stop, sent, rt, ctx } = start() t.after(stop) await team(call, sent) await rt.load() @@ -1148,7 +1151,7 @@ test('#476: the face room lead plans and assigns; other Bots do their part; the const running = relay(hosted.id, 'lead-plain') const first = await turnFor(sent) assert.equal(first.sessionId, hosted.sessions[writer]) - const prompt = first.message.content[0].text + const prompt = standingOf(ctx, first.sessionId) assert.match(prompt, /You are this room's lead \(总负责人\)/) assert.match(prompt, /write the one plan/) assert.match(prompt, /- The user: Chief: Chief of Staff; Writer \(you, room lead\): Copywriter/) @@ -1173,7 +1176,7 @@ test('#476: the face room lead plans and assigns; other Bots do their part; the } await next assert.ok(turns.length > 0) - const asked = turns[0].message.content[0].text + const asked = standingOf(ctx, turns[0].sessionId) assert.match(asked, /Coder \(Bob\) is this room's lead \(总负责人\)/) assert.match(asked, /You are not the lead\. Do only the part assigned to you, or answer when you are named\. Don't re-plan/) assert.match(asked, /- Bob's Bots: Coder \(room lead\): Developer/) @@ -1488,3 +1491,115 @@ test("#804: a member's @Admin is honoured after the admin already spoke in the r const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer', 'Chief', 'Third', 'Chief']) }) + +// #894 (#796): the group's standing rules (members, @Name calls, the notice, the reply rule) are the +// group's system-prompt section, read on every model call; each turn's message carries only the round. +// Before, every turn repeated them, so the member's Session history kept one copy per turn. +test('a member reads the group\'s rules and notice from its standing section, and no turn repeats them', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent, ctx, rt } = start() + t.after(stop) + const room = await team(call, sent) + const notice = 'Rule one: answer with the tip SHA.\nRule two: never paste a key.' + await call('update-room', { id: room.id, notice }) + sent.length = 0 + const texts = [] + for (const round of [1, 2]) { + await emit(room.id, userMessage(`Status ${round}?`)) + const running = relay(room.id, `standing-${round}`) + const seen = [...sent] + for (let at = 0; at < 2; at += 1) { + const turn = await turnFor(sent, ...seen) + seen.push(turn) + texts.push({ sessionId: turn.sessionId, text: turn.message.content[0].text }) + await answer(turn.sessionId, turn.message.source.exchangeId, `Done ${round}.${at}`, round * 10 + at) + } + await running + } + assert.equal(texts.length, 4) + for (const { sessionId, text } of texts) { + const standing = standingOf(ctx, sessionId) + assert.match(standing, /^# This group chat/) + assert.ok(standing.includes(`Group notice (shared instructions for every member):\n${notice}`), 'the notice is in the section') + assert.match(standing, /Members: the user, /) + assert.match(standing, /Writing "@Name" calls that member next/) + assert.match(standing, /Reply \[PASS\] to stay silent/) + assert.ok(!text.includes('Rule one'), 'no turn repeats the notice') + assert.doesNotMatch(text, /Members: the user|Writing "@Name" calls|Group notice|You are this group's admin/) + assert.match(text, /Speaking order for this round/) + assert.match(text, /Reply now with your message for the group, no name prefix, or \[PASS\] to stay silent\.$/) + } + // The admin's own rules are its section's, not another member's. + const chief = (await call('state', {})).value.mainBotId + await rt.load() + const live = rt.roomOf(room.id) + assert.match(standingOf(ctx, live.sessions[chief]), /You are this group's admin\./) + assert.doesNotMatch(standingOf(ctx, texts.find(entry => entry.sessionId !== live.sessions[chief]).sessionId), /You are this group's admin/) + // A Bot's own chat and the group's relay Session get no group section, so their prompts are unchanged. + assert.equal(standingOf(ctx, rt.chatOf(chief)), '') + assert.equal(standingOf(ctx, room.id), '') + assert.equal(standingOf(ctx, undefined), '') +}) + +test('a changed notice reaches every member\'s section at once, and its next turn says only that it changed', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent, ctx } = start() + t.after(stop) + const room = await team(call, sent) + await call('update-room', { id: room.id, notice: 'Old rule.' }) + const round = async (label) => { + sent.length = 0 + await emit(room.id, userMessage(`Line ${label}`)) + const running = relay(room.id, `notice-${label}`) + const turns = [] + for (let at = 0; at < 2; at += 1) { + const turn = await turnFor(sent, ...turns) + turns.push(turn) + await answer(turn.sessionId, turn.message.source.exchangeId, `ok ${label}`, at) + } + await running + return turns.map(turn => ({ sessionId: turn.sessionId, text: turn.message.content[0].text })) + } + const first = await round('a') + for (const { text } of first) assert.doesNotMatch(text, /notice changed/, 'a first turn has nothing to compare with') + await call('update-room', { id: room.id, notice: 'New rule: reply in English.' }) + for (const { sessionId } of first) { + assert.match(standingOf(ctx, sessionId), /New rule: reply in English\./, 'the section holds the new notice before anyone speaks') + assert.doesNotMatch(standingOf(ctx, sessionId), /Old rule/) + } + const second = await round('b') + for (const { text } of second) { + assert.match(text, /The group notice changed since your last turn here; the new notice is in your instructions for this group\./) + assert.ok(!text.includes('New rule: reply in English.'), 'the turn does not repeat the notice itself') + } + const third = await round('c') + for (const { text } of third) assert.doesNotMatch(text, /notice changed/, 'said once, not on every turn after') + await call('update-room', { id: room.id, notice: '' }) + const fourth = await round('d') + for (const { sessionId, text } of fourth) { + assert.match(text, /The group notice was cleared since your last turn here\./) + assert.doesNotMatch(standingOf(ctx, sessionId), /Group notice/) + } +}) + +// #894 (#796): a face room's roster, lead rules and the rule for lines that name no Bot are its standing +// section too; a routed turn says only what this line adds (who spoke last, who already answered). +test('a face room\'s roster and routing rule stand in its section; a routed turn carries a short note', async (t) => { + const env = start() + const { emit, userMessage, relay, stop, ctx } = env + t.after(stop) + const { hosted, writer } = await routedRoom(env, 'standing') + await emit(hosted.id, userMessage('帮我看看文档怎么写')) + const turns = await passAll(env, relay(hosted.id, 'standing-1'), 0) + assert.ok(turns.length >= 2) + for (const turn of turns.slice(0, 2)) { + const text = promptOf(turn) + const standing = standingOf(ctx, turn.sessionId) + assert.match(standing, /This is a face-to-face room with people from several homes/) + assert.match(standing, /Writer( \(you\))?: Writes docs and copy/) + assert.match(standing, /Default to \[PASS\]: exactly one Bot answers a line like this, and the first Bot that answers ends it/) + assert.match(standing, /A line addressed to everyone .* is the exception/) + assert.match(standing, /\[PASS\] posts nothing and nobody sees it: never explain a pass\./) + assert.match(text, /This line names no Bot\. Default to \[PASS\]: exactly one Bot answers it; answer only if it is for you, by the rule for such lines in your instructions for this group\./) + assert.doesNotMatch(text, /face-to-face room with people|What members do|never explain a pass|the first Bot that answers ends it|Writing "@Name" calls/) + } + assert.ok(turns.some(turn => turn.sessionId === hosted.sessions[writer])) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index dd74e583..60fae54b 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -63,6 +63,10 @@ context rollover for execution events. This internal DSH boundary adds no external backend or task-specific session mapping; see the [same-room participant contract](../interop/room-participants.md) for those gaps. +### Group standing section (#894) + +A member's group Session reads the group's fixed rules from a system-prompt section, `bot-room` (order 10150, after DSH's own sections so the member's group Session and its own chat share the longest system-prompt prefix), not from each turn's message. `groups.js` `standingText` renders it from room settings only: the members line (or the face roster), `@Name` calls, the notice, the admin note or the face lead / member note, the face rule for lines that name no Bot (`routeRules`: the member's default, the exception for a line to everyone, other homes' Bots answering in their own home, never explain a pass), and `ROOM_REPLY`. DSH renders sections on every model step and re-projects them after a checkpoint or in a new part, so the rules are always in front of the model while the history keeps one copy per change instead of one per turn. The own chat and the group's relay Session render no section (`''`). Each turn's message keeps the round (earlier lines, this round, references, who is still working, speaking order, position), a short `routeNote` (this line: to everyone, the lead's or "Default to [PASS]", who spoke last, who already answered), and `ROOM_REPLY_NOW` (or `ROOM_REPLY_FORCED`). `room.noticeSeen[botId]` keeps a 16-hex SHA-256 mark of the notice a member last saw, so its next turn says only that the notice changed or was cleared. DSH runtime context (`systemPrompt.context`) is not used: its `runtime-context` user messages are not a no-request kind in `turn-origin.js`. Covered by `core/test/rounds.test.js` and `core/test/context-sharing.test.js`. + ### Group loop guards (#557) `post_to_group` has no cap on the rounds Bots start in one group between the user's lines. The old `ROOM_BOT_ROUNDS` (3) gate was turned off by owner decision 2026-10-21 07:34 Asia/Shanghai: it refused a recurring scheduled task's fourth post with a tool result only, so owner-set runs stopped overnight with nothing shown in the group. A saved room may still carry `botRounds`; nothing reads it, and `channel.js` keeps it out of the roster. The guards that stay: `MAX_HOPS` (6) ends a Bot-to-Bot chain (`message_bot`, and `post_to_group` from a relayed turn); `MAX_UNREAD_REQUESTS` (10) per Bot chat; `ROOM_TURNS_EACH` / `ROOM_ADMIN_TURNS_EACH` (2 / 4) per member per round, except the room's admin when a member names it with `@`: that call is never dropped by the cap (#804), because it is a report to the coordinator rather than a chain, and the round's own turn limit still bounds the round; `ROOM_LATE_ROUNDS` (10) late replies answered before the user speaks again; and a Bot cannot `post_to_group` into the room it is speaking in. Face-to-face rooms take the same path. Covered by `core/test/rounds.test.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 19303df1..82c563fd 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1,3 +1,5 @@ +FlyBot patch 0001-core-fixes on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/). + diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -1810,7 +1812,7 @@ diff --git a/src/client/text.js b/src/client/text.js // The prompts in a due scheduled task's message. DSH frames it for the model: one // task as `reminder_prompt_json: "…"`, several as `reminders_json: [{reminder_prompt}]`. export function reminderPrompts(text) { -@@ -62,6 +69,15 @@ export function reminderPrompts(text) { +@@ -62,6 +62,15 @@ export function reminderPrompts(text) { } } export const stripHeader = text => text.replace(HEADER, '') @@ -1826,7 +1828,7 @@ diff --git a/src/client/text.js b/src/client/text.js // Of the Bots in a conversation, the one whose memory changed last, by the host's // clock: changes reaching one window in the same poll share a `seen` time. -@@ -95,6 +102,13 @@ export function summaryBlocks(text) { +@@ -95,6 +95,13 @@ export function summaryBlocks(text) { return blocks } @@ -1840,7 +1842,7 @@ diff --git a/src/client/text.js b/src/client/text.js export function isHiddenTurn(roster, sessionId, turn) { if (sessionId === undefined || turn === undefined) return false return (roster.exchangeTurns[sessionId] ?? []).includes(turn) -@@ -102,6 +109,13 @@ export function isHiddenTurn(roster, sessionId, turn) { +@@ -102,6 +102,13 @@ export function isHiddenTurn(roster, sessionId, turn) { export const toolName = root => root.name || root.call?.name @@ -1854,7 +1856,7 @@ diff --git a/src/client/text.js b/src/client/text.js // While a Session's history loads, a card asked in it may yet be drawn at its call, // so the docks hold it back until then. export const historyLoading = snapshot => snapshot?.openState === 'cold' || snapshot?.openState === 'loading' -@@ -125,7 +132,7 @@ export function toolArgs(root) { +@@ -125,7 +125,7 @@ export function toolArgs(root) { } // A relay context is a Bot-to-Bot request, reply or stop notice injected into the @@ -1863,7 +1865,7 @@ diff --git a/src/client/text.js b/src/client/text.js // parts) are not events in the chat, nor is a request handed back after its line was // already shown: deferred, resumed after a restart, or retried after a tool call // written as text. -@@ -200,14 +207,26 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -200,14 +200,26 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { } // Latest visible line for a sidebar row; Bot-to-Bot exchange turns stay out of it. @@ -1893,7 +1895,7 @@ diff --git a/src/client/text.js b/src/client/text.js } return '' } -@@ -230,3 +237,35 @@ export function usageOf(list, ids) { +@@ -230,3 +230,35 @@ export function usageOf(list, ids) { const hit = input > 0 ? Math.round((cacheRead / input) * 100) : 0 return t('{tokens} tok · Cache hit {hit}%', { tokens: formatTokens(input + output), hit }) } @@ -6436,6 +6438,15 @@ diff --git a/test/images.test.js b/test/images.test.js diff --git a/test/install.test.js b/test/install.test.js --- a/test/install.test.js +++ b/test/install.test.js +@@ -61,6 +61,8 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { + 'tool attach_capability', + 'tool detach_capability', + 'effect ds-bot: chat index', ++ 'effect ds-bot: group chat section', ++ 'prompt bot-room 10150', + 'tool group_relay', + 'tool read_own_chat', + 'tool read_group_chat', @@ -91,12 +91,23 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on llm/stream', 'effect ds-bot: load secrets', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 174bad39..909a5532 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1,3 +1,5 @@ +FlyBot patch 0003-flybot-api-client on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/). + diff --git a/src/client/activity-row.js b/src/client/activity-row.js --- a/src/client/activity-row.js +++ b/src/client/activity-row.js @@ -2310,7 +2312,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js return () => clearTimeout(timer) }, [mainView, roster]) } -@@ -572,9 +574,9 @@ export function FooterAccount(props) { +@@ -572,9 +572,9 @@ export function FooterAccount(props) { export function BotReturn({ wide, useSurface, actions }) { const mode = useSurface(value => value.mode) if (mode !== 'agent') return null @@ -2323,7 +2325,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js } // Agent mode has none of the shell-group styles, so the return button carries its own. -@@ -582,22 +584,22 @@ export const RETURN_CSS = ` +@@ -582,22 +582,22 @@ export const RETURN_CSS = ` .bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} .bt-return:hover{background:rgba(127,127,127,.14)} .bt-return:active{transform:scale(.96)} @@ -2351,7 +2353,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js // shell's footer classes are CSS-module names, so they match on their stems. export const SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -658,4 +660,14 @@ export const SIDEBAR_CSS = ` +@@ -658,4 +658,14 @@ export const SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 62419bc5..582d7791 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1087,6 +1087,15 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js diff --git a/test/rounds.test.js b/test/rounds.test.js --- a/test/rounds.test.js +++ b/test/rounds.test.js +@@ -114,6 +114,8 @@ const waitFor = async (check) => { + return undefined + } + const turnFor = (sent, ...earlier) => waitFor(() => sent.find(entry => !earlier.includes(entry) && entry.message.source.role === 'room')) ++// The group's standing rules for a member: its system-prompt section, read the way DSH renders it per call. ++const standingOf = (ctx, sessionId) => ctx.sections.get('bot-room').text({ agent: { id: sessionId } }) + + test('a new group Session waits for capabilities before its first member prompt', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() @@ -180,6 +180,18 @@ test('a message the composer queues mid-round joins the round and leaves the Ses assert.deepEqual(inboxOf(room.id).nextTurn, []) }) @@ -1551,7 +1560,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) -@@ -347,3 +347,780 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,893 @@ test('a message sent between rounds still opens a full round', async (t) => { const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) @@ -1593,8 +1602,9 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + const first = await turnFor(sent) + assert.match(promptOf(first), /names no Bot/, 'the line is routed: each Bot decides whether it is theirs') + assert.match(promptOf(first), /\[PASS\]/) -+ assert.match(promptOf(first), /Writer: Writes docs and copy/, 'members see what each does') -+ assert.match(promptOf(first), /Bob's Chief|Chief/, 'friend\'s Bots are named as answering for themselves') ++ // The roster with each Bot's role and the standing rule for such lines are the room's section (#894). ++ assert.match(standingOf(env.ctx, first.sessionId), /Writer: Writes docs and copy/, 'members see what each does') ++ assert.match(standingOf(env.ctx, first.sessionId), /Other owners' Bots here answer for themselves in their own home: .*Chief/, 'friend\'s Bots are named as answering for themselves') + await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1) + const second = await turnFor(sent, first) + assert.ok(second) @@ -1737,7 +1747,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + const { running: one } = await say('文档怎么写', 'real-1') + const a = await turnFor(sent) + assert.match(promptOf(a), /Default to \[PASS\]/) -+ assert.match(promptOf(a), /the first Bot that answers ends it/) ++ assert.match(standingOf(env.ctx, a.sessionId), /the first Bot that answers ends it/) + await answer(a.sessionId, a.message.source.exchangeId, '[PASS]', 1) + const b = await turnFor(sent, a) + assert.equal(b.sessionId, hosted.sessions[writer]) @@ -1967,7 +1977,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js +}) + +test('#476: the face room lead plans and assigns; other Bots do their part; the lead moves with the room in the next round', async (t) => { -+ const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() ++ const { call, emit, userMessage, relay, answer, stop, sent, rt, ctx } = start() + t.after(stop) + await team(call, sent) + await rt.load() @@ -1992,7 +2002,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + const running = relay(hosted.id, 'lead-plain') + const first = await turnFor(sent) + assert.equal(first.sessionId, hosted.sessions[writer]) -+ const prompt = first.message.content[0].text ++ const prompt = standingOf(ctx, first.sessionId) + assert.match(prompt, /You are this room's lead \(总负责人\)/) + assert.match(prompt, /write the one plan/) + assert.match(prompt, /- The user: Chief: Chief of Staff; Writer \(you, room lead\): Copywriter/) @@ -2017,7 +2027,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + } + await next + assert.ok(turns.length > 0) -+ const asked = turns[0].message.content[0].text ++ const asked = standingOf(ctx, turns[0].sessionId) + assert.match(asked, /Coder \(Bob\) is this room's lead \(总负责人\)/) + assert.match(asked, /You are not the lead\. Do only the part assigned to you, or answer when you are named\. Don't re-plan/) + assert.match(asked, /- Bob's Bots: Coder \(room lead\): Developer/) @@ -2332,3 +2342,115 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + const result = JSON.parse(await running) + assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer', 'Chief', 'Third', 'Chief']) +}) ++ ++// #894 (#796): the group's standing rules (members, @Name calls, the notice, the reply rule) are the ++// group's system-prompt section, read on every model call; each turn's message carries only the round. ++// Before, every turn repeated them, so the member's Session history kept one copy per turn. ++test('a member reads the group\'s rules and notice from its standing section, and no turn repeats them', async (t) => { ++ const { call, emit, userMessage, relay, answer, stop, sent, ctx, rt } = start() ++ t.after(stop) ++ const room = await team(call, sent) ++ const notice = 'Rule one: answer with the tip SHA.\nRule two: never paste a key.' ++ await call('update-room', { id: room.id, notice }) ++ sent.length = 0 ++ const texts = [] ++ for (const round of [1, 2]) { ++ await emit(room.id, userMessage(`Status ${round}?`)) ++ const running = relay(room.id, `standing-${round}`) ++ const seen = [...sent] ++ for (let at = 0; at < 2; at += 1) { ++ const turn = await turnFor(sent, ...seen) ++ seen.push(turn) ++ texts.push({ sessionId: turn.sessionId, text: turn.message.content[0].text }) ++ await answer(turn.sessionId, turn.message.source.exchangeId, `Done ${round}.${at}`, round * 10 + at) ++ } ++ await running ++ } ++ assert.equal(texts.length, 4) ++ for (const { sessionId, text } of texts) { ++ const standing = standingOf(ctx, sessionId) ++ assert.match(standing, /^# This group chat/) ++ assert.ok(standing.includes(`Group notice (shared instructions for every member):\n${notice}`), 'the notice is in the section') ++ assert.match(standing, /Members: the user, /) ++ assert.match(standing, /Writing "@Name" calls that member next/) ++ assert.match(standing, /Reply \[PASS\] to stay silent/) ++ assert.ok(!text.includes('Rule one'), 'no turn repeats the notice') ++ assert.doesNotMatch(text, /Members: the user|Writing "@Name" calls|Group notice|You are this group's admin/) ++ assert.match(text, /Speaking order for this round/) ++ assert.match(text, /Reply now with your message for the group, no name prefix, or \[PASS\] to stay silent\.$/) ++ } ++ // The admin's own rules are its section's, not another member's. ++ const chief = (await call('state', {})).value.mainBotId ++ await rt.load() ++ const live = rt.roomOf(room.id) ++ assert.match(standingOf(ctx, live.sessions[chief]), /You are this group's admin\./) ++ assert.doesNotMatch(standingOf(ctx, texts.find(entry => entry.sessionId !== live.sessions[chief]).sessionId), /You are this group's admin/) ++ // A Bot's own chat and the group's relay Session get no group section, so their prompts are unchanged. ++ assert.equal(standingOf(ctx, rt.chatOf(chief)), '') ++ assert.equal(standingOf(ctx, room.id), '') ++ assert.equal(standingOf(ctx, undefined), '') ++}) ++ ++test('a changed notice reaches every member\'s section at once, and its next turn says only that it changed', async (t) => { ++ const { call, emit, userMessage, relay, answer, stop, sent, ctx } = start() ++ t.after(stop) ++ const room = await team(call, sent) ++ await call('update-room', { id: room.id, notice: 'Old rule.' }) ++ const round = async (label) => { ++ sent.length = 0 ++ await emit(room.id, userMessage(`Line ${label}`)) ++ const running = relay(room.id, `notice-${label}`) ++ const turns = [] ++ for (let at = 0; at < 2; at += 1) { ++ const turn = await turnFor(sent, ...turns) ++ turns.push(turn) ++ await answer(turn.sessionId, turn.message.source.exchangeId, `ok ${label}`, at) ++ } ++ await running ++ return turns.map(turn => ({ sessionId: turn.sessionId, text: turn.message.content[0].text })) ++ } ++ const first = await round('a') ++ for (const { text } of first) assert.doesNotMatch(text, /notice changed/, 'a first turn has nothing to compare with') ++ await call('update-room', { id: room.id, notice: 'New rule: reply in English.' }) ++ for (const { sessionId } of first) { ++ assert.match(standingOf(ctx, sessionId), /New rule: reply in English\./, 'the section holds the new notice before anyone speaks') ++ assert.doesNotMatch(standingOf(ctx, sessionId), /Old rule/) ++ } ++ const second = await round('b') ++ for (const { text } of second) { ++ assert.match(text, /The group notice changed since your last turn here; the new notice is in your instructions for this group\./) ++ assert.ok(!text.includes('New rule: reply in English.'), 'the turn does not repeat the notice itself') ++ } ++ const third = await round('c') ++ for (const { text } of third) assert.doesNotMatch(text, /notice changed/, 'said once, not on every turn after') ++ await call('update-room', { id: room.id, notice: '' }) ++ const fourth = await round('d') ++ for (const { sessionId, text } of fourth) { ++ assert.match(text, /The group notice was cleared since your last turn here\./) ++ assert.doesNotMatch(standingOf(ctx, sessionId), /Group notice/) ++ } ++}) ++ ++// #894 (#796): a face room's roster, lead rules and the rule for lines that name no Bot are its standing ++// section too; a routed turn says only what this line adds (who spoke last, who already answered). ++test('a face room\'s roster and routing rule stand in its section; a routed turn carries a short note', async (t) => { ++ const env = start() ++ const { emit, userMessage, relay, stop, ctx } = env ++ t.after(stop) ++ const { hosted, writer } = await routedRoom(env, 'standing') ++ await emit(hosted.id, userMessage('帮我看看文档怎么写')) ++ const turns = await passAll(env, relay(hosted.id, 'standing-1'), 0) ++ assert.ok(turns.length >= 2) ++ for (const turn of turns.slice(0, 2)) { ++ const text = promptOf(turn) ++ const standing = standingOf(ctx, turn.sessionId) ++ assert.match(standing, /This is a face-to-face room with people from several homes/) ++ assert.match(standing, /Writer( \(you\))?: Writes docs and copy/) ++ assert.match(standing, /Default to \[PASS\]: exactly one Bot answers a line like this, and the first Bot that answers ends it/) ++ assert.match(standing, /A line addressed to everyone .* is the exception/) ++ assert.match(standing, /\[PASS\] posts nothing and nobody sees it: never explain a pass\./) ++ assert.match(text, /This line names no Bot\. Default to \[PASS\]: exactly one Bot answers it; answer only if it is for you, by the rule for such lines in your instructions for this group\./) ++ assert.doesNotMatch(text, /face-to-face room with people|What members do|never explain a pass|the first Bot that answers ends it|Writing "@Name" calls/) ++ } ++ assert.ok(turns.some(turn => turn.sessionId === hosted.sessions[writer])) ++}) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 61b6aa74..f917b8e9 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -621,7 +621,7 @@ diff --git a/src/client/sources.js b/src/client/sources.js diff --git a/src/host/groups.js b/src/host/groups.js --- a/src/host/groups.js +++ b/src/host/groups.js -@@ -7,8 +7,10 @@ +@@ -7,21 +7,29 @@ // the speaking order and the member's place in it. Nobody answers a room that has // moved on, and nobody has to count lines to know who went before. A plain message // goes to every member in roster order, admin first, or to the admin alone in admin @@ -634,9 +634,10 @@ diff --git a/src/host/groups.js b/src/host/groups.js // // Work does not hold a round up: a member that is still busy with tools a little after // it started, or that is called while still at earlier work, lets the round go on. Its -@@ -16,12 +16,18 @@ + // turn keeps running in its group Session, and what it ends with is posted to the group, // where the member that asked for it answers. - import { randomUUID } from 'node:crypto' +-import { randomUUID } from 'node:crypto' ++import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { ROOM_LATE_ROUNDS, ROOM_REPLY_TIMEOUT_MS, ROOM_WORK_GRACE_MS, SOURCE_KIND, TEAM_TOOLS } from './constants.js' @@ -651,7 +652,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js +import { currentParticipants, isFaceRoom } from './room-membership.js' +import { FACE_LEAD_NOTE, FACE_LEAD_ROUTE, faceLeadIsRemote, faceMemberNote, faceRoster, isFaceLead } from './face-lead.js' +import { faceMentions } from './face-mentions.js' -+import { stripSessionChips } from './delivery.js' ++import { cleanName, stripSessionChips } from './delivery.js' +import { createDshRoomParticipant } from './room-participant.js' const ROOM_LOG_MAX = 60 @@ -743,17 +744,19 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -86,6 +86,9 @@ export const referenceNote = references => (references?.length +@@ -86,6 +86,11 @@ export const referenceNote = references => (references?.length : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' ++// The turn's own closing line: the full reply rule above stands in the group's section of the system prompt. ++const ROOM_REPLY_NOW = 'Reply now with your message for the group, no name prefix, or [PASS] to stay silent.' +// Words that address the whole room: a plain line with one of them may get an answer from each Bot (#299). +const ROOM_EVERYONE = /大家|你们|各位|所有人|所有 ?[Bb]ot|每个人|每位|各自|分别|everyone|everybody|y'?all|you all|all of you|each of you|introduce yourselves/i -+const ROOM_REPLY_FORCED = 'Reply with your message for the group, no name prefix, short. Do not reply [PASS].' ++const ROOM_REPLY_FORCED = 'This turn is the exception to the [PASS] rule: reply with your message for the group, no name prefix, short. Do not reply [PASS].' const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { -@@ -100,44 +100,70 @@ const slotLabel = (slot) => { +@@ -100,52 +100,138 @@ const slotLabel = (slot) => { return slot.name } @@ -769,6 +772,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js -const roomKickoff = language => `The user just created this group and has not written yet. Greet the user in one short message and ask what they want this group to work on. Do not call members with @ yet, and do not reply [PASS]. No name prefix. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.` - const ROOM_READ_MAX = 30 ++// The group's standing section sits after DSH's own sections, so a member's group Session shares the ++// longest possible system-prompt prefix with its own chat, and before the persona suffix that closes it. ++const ROOM_SECTION_ORDER = 10150 ++// What a member last saw of the notice, so its next turn says when the notice changed. ++const noticeMark = notice => createHash('sha256').update(String(notice ?? '')).digest('hex').slice(0, 16) export function install(rt) { const { ctx } = rt @@ -815,53 +823,118 @@ diff --git a/src/host/groups.js b/src/host/groups.js + owners.delete(undefined) + if (owners.size <= 1) return true + return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) -+ } -+ + } + +- // Lines from before the round's opening message are kept apart from this round, so +- // an earlier round (an old count-off, say) never reads as part of the current one. +- function roomPrompt(room, member, round) { + async function groupSessionFor(room, member) { + const session = await participant.open({ room, member }) + return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } + } + -+ // A plain line in a face-to-face room (#299): the Bots decide who answers. Each gets this note, so one -+ // Bot answers a specific question, a follow-up goes to the Bot that just spoke, and a line to everyone may -+ // get a short answer from each. A pass posts nothing. ++ // A plain line in a face-to-face room (#299): the Bots decide who answers. The rule for such lines is ++ // in the room's standing section (routeRules); each routed turn carries this short note with what ++ // only this line can say, so one Bot answers a specific question, a follow-up goes to the Bot that ++ // just spoke, and a line to everyone may get a short answer from each. A pass posts nothing. + function routeNote(room, member, round) { + const { broadcast, last, forced } = round.route + if (forced === member.id) return 'The user asked this and nobody else answered. Answer now: if the line is plainly for another owner\'s Bot, say that in one short sentence.' -+ const everyone = room.members.map(rt.botOf).filter(Boolean) -+ const roles = everyone.filter(bot => bot.role).map(bot => `${bot.name}${bot.id === member.id ? ' (you)' : ''}: ${String(bot.role).slice(0, 80)}`) -+ const friends = currentParticipants(room).filter(p => p.kind === 'bot' && p.remote).map(p => p.label || p.displayName).filter(Boolean) + const answered = friendAnswers(room, round) + return [ -+ 'This line names no Bot, and the group should get the right answer, not one from everyone. Decide whether it is yours.', + broadcast -+ ? 'It is addressed to everyone (introductions, what each of you can do, a round of opinions): give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.' ++ ? 'This line names no Bot and is addressed to everyone: give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.' + : isFaceLead(room, member) -+ ? FACE_LEAD_ROUTE -+ : 'Default to [PASS]: exactly one Bot answers a line like this, and the first Bot that answers ends it, so the Bots after it are not asked. Answer only if it is for you: it names you or your role, no other member\'s role fits it better, or it follows up on your own last answer. Reply [PASS] when it is for another Bot by name or role, when it follows up on another Bot\'s answer, or when you would only be guessing.', ++ ? 'This line names no Bot. You are the room lead, so a line like this is yours: answer it, by the rule for such lines in your instructions for this group.' ++ : 'This line names no Bot. Default to [PASS]: exactly one Bot answers it; answer only if it is for you, by the rule for such lines in your instructions for this group.', + last && !broadcast ? `${last.name} spoke last before this line: a follow-up to what ${last.name} said ("what did you mean", "and then?", "say more") is for ${last.name}.` : '', -+ roles.length > 0 ? `What members do: ${roles.join('; ')}.` : '', -+ friends.length > 0 ? `Other owners' Bots here answer for themselves in their own home: ${friends.join(', ')}.` : '', + answered.length > 0 ? `${answered.join(', ')} already answered this line or posted a plan; add only your role's part, and do not re-plan or repeat it.` : '', ++ ].filter(Boolean).join(' ') ++ } ++ // The standing rule for a face room's lines that name no Bot (#299, #476): the member's own default, ++ // the exception for a line to everyone, and the Bots that answer in their own home. ++ function routeRules(room, member) { ++ if (!isFaceRoom(room)) return '' ++ const friends = currentParticipants(room).filter(p => p.kind === 'bot' && p.remote).map(p => cleanName(p.label || p.displayName, '')).filter(Boolean) ++ return [ ++ 'A line from a person that names no Bot: the group should get the right answer, not one from everyone, so each Bot decides whether it is its own.', ++ isFaceLead(room, member) ++ ? FACE_LEAD_ROUTE ++ : 'Default to [PASS]: exactly one Bot answers a line like this, and the first Bot that answers ends it, so the Bots after it are not asked. Answer only if it is for you: it names you or your role, no other member\'s role fits it better (each Bot\'s role is in the list above), or it follows up on your own last answer. Reply [PASS] when it is for another Bot by name or role, when it follows up on another Bot\'s answer, or when you would only be guessing.', ++ 'A line addressed to everyone (introductions, what each of you can do, a round of opinions) is the exception: give your own short answer, but reply [PASS] if it would only repeat what a Bot before you said.', ++ friends.length > 0 ? `Other owners' Bots here answer for themselves in their own home: ${friends.join(', ')}.` : '', + '[PASS] posts nothing and nobody sees it: never explain a pass.', + ].filter(Boolean).join(' ') - } - - // Lines from before the round's opening message are kept apart from this round, so -@@ -153,14 +153,13 @@ export function install(rt) { ++ } ++ ++ // What holds for every turn of a member in this group (#894): who is here, how calls work, the ++ // notice, the member's own role here and the reply rule. It is a section of the system prompt, so ++ // the model reads it on every call and a checkpoint or a new part never drops it, while the ++ // Session's history keeps one copy per change instead of one per turn. Only room settings go in ++ // it, never the round, so it changes when the group does. Peer names and roles in a face roster ++ // are cleaned by faceRoster (one line each, capped, no markup). ++ function standingText(room, member) { + const peers = room.members.filter(id => id !== member.id).map(rt.botOf).filter(Boolean) + .map(bot => `${bot.name}${rt.isAdminOf(room, bot.id) ? ' (admin)' : ''}`) + const leads = rt.isAdminOf(room, member.id) ++ const members = `${isFaceRoom(room) ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` ++ return [ ++ '# This group chat', ++ 'This conversation is where you speak in one group chat. Each turn arrives as a "[Group chat …]" message with what is new and the speaking order; the rules below hold for every turn here.', ++ members, ++ room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '', ++ leads ? adminNote(room) : faceMemberNote(room, member, rt.botOf), ++ routeRules(room, member), ++ ROOM_REPLY, ++ ].filter(Boolean).join('\n\n') ++ } ++ const roomSectionFor = (sessionId) => { ++ const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId) ++ const room = rt.roomOf(owner?.roomId) ++ const member = rt.botOf(owner?.botId) ++ if (room === undefined || member === undefined || !room.members.includes(member.id)) return '' ++ return standingText(room, member) ++ } ++ ctx.effect(() => ctx.systemPrompt.section({ ++ name: 'bot-room', ++ order: ROOM_SECTION_ORDER, ++ interpolate: false, ++ text: context => roomSectionFor(context?.agent?.id), ++ }), 'ds-bot: group chat section') ++ ++ // A notice that changed since this member's last turn here: the section already holds the new ++ // text, so the turn only says that it changed. Marks the notice seen. ++ function noticeChange(room, member) { ++ room.noticeSeen ??= {} ++ const before = room.noticeSeen[member.id] ++ const now = noticeMark(room.notice) ++ room.noticeSeen[member.id] = now ++ if (before === undefined || before === now) return '' ++ return room.notice ++ ? 'The group notice changed since your last turn here; the new notice is in your instructions for this group.' ++ : 'The group notice was cleared since your last turn here.' ++ } ++ ++ // Lines from before the round's opening message are kept apart from this round, so ++ // an earlier round (an old count-off, say) never reads as part of the current one. ++ // The group's standing rules are not repeated here: they are in the section above. ++ function roomPrompt(room, member, round) { + const lines = unseenLines(room, member.id) + const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) + const current = lines.filter(line => line.seq >= round.startSeq) +@@ -153,14 +153,9 @@ export function install(rt) { const index = round.slots.findIndex(slot => slot.status === 'speaking') const before = index const after = round.slots.length - index - 1 - const members = `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` -+ const members = `${isFaceRoom(room) ? `${faceRoster(room, member, rt.botOf)}\n` : `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. `}Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}` - const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '' +- const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '' - if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n') const working = workingIn(room).filter(item => item.bot.id !== member.id) return [ - members, - notice, +- members, +- notice, - leads ? adminNote(room) : '', -+ leads ? adminNote(room) : faceMemberNote(room, member, rt.botOf), ++ noticeChange(room, member), earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', current.length ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}` @@ -871,7 +944,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`, - ROOM_REPLY, + round.route ? routeNote(room, member, round) : '', -+ round.route?.forced === member.id ? ROOM_REPLY_FORCED : ROOM_REPLY, ++ round.route?.forced === member.id ? ROOM_REPLY_FORCED : ROOM_REPLY_NOW, ].filter(Boolean).join('\n\n') } diff --git a/patches/ds-bot/files/test/context-sharing.test.js b/patches/ds-bot/files/test/context-sharing.test.js index 39cc4b80..4c483f3a 100644 --- a/patches/ds-bot/files/test/context-sharing.test.js +++ b/patches/ds-bot/files/test/context-sharing.test.js @@ -126,7 +126,8 @@ async function start(t, config = {}) { if (memberIds().length >= expected && memberIds().every(id => roundTurns.has(id))) break await new Promise(resolve => setImmediate(resolve)) } - return memberIds().map(id => textOf(agents.get(id).calls[0])) + // The group's standing rules (members, notice) ride in its system-prompt section, the turn in the message. + return memberIds().map(id => `${ctx.sections.get('bot-room')?.text({ agent: { id } }) ?? ''}\n${textOf(agents.get(id).calls[0])}`) } return { ctx, call, emit, tool, agentOf, signIn, home, runRound } }