diff --git a/changelog.d/2774-part-checkpoint-context.fixed.md b/changelog.d/2774-part-checkpoint-context.fixed.md new file mode 100644 index 000000000..1d10dbcdf --- /dev/null +++ b/changelog.d/2774-part-checkpoint-context.fixed.md @@ -0,0 +1 @@ +`read_own_chat({ part: N })` no longer calls the current part "in your context" after a checkpoint condensed it. When the part carries a `[Checkpoint N]`, the answer names the checkpoint, shows the newest messages that are still in context, and points at the words search for the rest; without a checkpoint, and for an ended or missing part, the answer is unchanged. See [#2774](https://github.com/ThinkFlowLab/FlyBot/issues/2774). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 8b6a4507c..d93dd542d 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -32,6 +32,7 @@ import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js' import { createRoomParticipants } from './room-participant.js' import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine, setRoomLineRedactor } from './room-context.js' import { workDispatch } from './work-dispatch.js' +import { checkpointCount } from './steward.js' import { retiredBot } from './store.js' // #1253: how long an identical claim line keeps a card quiet after it was posted. A claim is a @@ -993,7 +994,24 @@ export function install(rt) { if (query === '' && number !== undefined) { const part = parts[number - 1] if (part === undefined) return `Your own chat has no part ${number}.${partsNote}` - if (part.endedAt === undefined) return `Part ${number} is the current one; it is in your context.${partsNote}` + if (part.endedAt === undefined) { + // A checkpoint replaces the part's older messages in place (#2774): the part is still + // the current one, but most of it left the context, so calling it context is false. + // A Session without deriveMessages (the test benches) and one whose deriveMessages + // throws both read as "no checkpoint": a read that used to answer keeps answering, + // instead of turning a broken Session into a failed tool call. + const agent = await rt.resolveAgent(part.sessionId).catch(() => undefined) + let checkpoints = 0 + try { + checkpoints = checkpointCount(agent?.session?.deriveMessages?.() ?? []) + } catch { + // The count is a message hint only, so a Session that cannot report its messages + // keeps the unchanged sentence rather than failing this read. + } + if (checkpoints === 0) return `Part ${number} is the current one; it is in your context.${partsNote}` + const { lines } = await rt.latestOwnChat(botId, { askingId: id }) + return `Part ${number} is the current one, but its earlier messages are condensed into [Checkpoint ${checkpoints}], so only its newest messages are still in your context:\n${lines.join('\n')}\nSearch it with words to read the rest word for word.${partsNote}` + } return `Part ${number} of your own chat (${rt.clock(part.startedAt)} – ${rt.clock(part.endedAt)}). The handoff note that closed it:\n${rt.noteText(part) ?? '(none: the part ended on a context overflow)'}${partsNote}` } if (query === '') { diff --git a/core/test/group-work.test.js b/core/test/group-work.test.js index 2e98ef091..540123f6f 100644 --- a/core/test/group-work.test.js +++ b/core/test/group-work.test.js @@ -572,3 +572,42 @@ test('a bare read that hits its cap says newest 12 of 15 (#1906)', async (t) => assert.equal(shownLines(capOut).length, 12) assert.doesNotMatch(capOut, /newest 12 of/) }) + +// #2774: a checkpoint condenses a part's older messages in place, so the current part is still +// current but no longer in context. The part read says which checkpoint replaced it and where +// the rest can still be read word for word. +const checkpointMessages = number => [ + { role: 'user', content: [{ type: 'text', text: `[Checkpoint ${number}]` }, { type: 'text', text: `\n\n\n[Checkpoint ${number}]\n- Done: outline\n` }] }, +] + +test('a current part condensed by a checkpoint is not called "in your context" (#2774)', async (t) => { + const history = { b: [chatLine(0, 'first line'), chatLine(1, 'second line'), chatLine(2, 'the url is https://example.test/xyz')] } + const host = await start(t, undefined, history) + host.agentOf('b').session.deriveMessages = () => checkpointMessages(1) + const out = await host.tool('read_own_chat', { part: 1 }, 'b') + assert.doesNotMatch(out, /it is in your context/, `still claims context: ${out}`) + assert.match(out, /Checkpoint 1/) + assert.match(out, /words/) + assert.match(out, /the url is https:\/\/example\.test\/xyz/, 'the newest lines are still shown') +}) + +test('a current part with no checkpoint, and an ended or missing part, answer as before (#2774)', async (t) => { + const history = { b: [chatLine(0, 'first line'), chatLine(1, 'second line')] } + const host = await start(t, undefined, history) + // This bench's Sessions have no deriveMessages: the check must stay optional, not throw. + assert.equal(await host.tool('read_own_chat', { part: 1 }, 'b'), 'Part 1 is the current one; it is in your context.') + host.rt.state.bots.b.segments = [{ sessionId: 'p1', endedAt: 2_000 }] + const ended = await host.tool('read_own_chat', { part: 1 }, 'b') + assert.match(ended, /^Part 1 of your own chat \(/) + assert.match(ended, /The handoff note that closed it:/) + assert.match(await host.tool('read_own_chat', { part: 9 }, 'b'), /^Your own chat has no part 9\./) +}) + +// A guard, not a red-first case: the Session's deriveMessages is only a hint, so one that throws +// must leave the read working and answering what it answered before #2774, never fail the tool. +test('a Session whose deriveMessages throws still reads the current part (#2774)', async (t) => { + const history = { b: [chatLine(0, 'first line'), chatLine(1, 'second line')] } + const host = await start(t, undefined, history) + host.agentOf('b').session.deriveMessages = () => { throw new Error('session is gone') } + assert.equal(await host.tool('read_own_chat', { part: 1 }, 'b'), 'Part 1 is the current one; it is in your context.') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 655bb1dde..54599c553 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -435,6 +435,12 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A bare `#` in an older anchor keeps that resolution for compatibility, but `aroundOwnChat` answers `guessedPart: true` and `exact: false` when the chat has more than one part (with one part the bare form is unambiguous), and `read_own_chat` says the part was a guess instead of claiming an exact hit. The tool's `around` description says the same. - Covered by `core/test/chat-anchors.test.js` (the real module, a temporary home, a real `chat-index.sqlite` and a fake Session log). +### A checkpointed current part is not called context (#2774) + +- A checkpoint condenses a part's older messages in place (`parts.js` swaps the Session's summarizer for `writeCheckpoint()`), so the current part can be mostly gone from the model's context while `read_own_chat({ part: N })` still called it `it is in your context`. `groups.js` now resolves the part's Session and counts its checkpoints with `steward.js` `checkpointCount(agent.session.deriveMessages())`: with none, the answer is the old sentence byte for byte; with one, it names `[Checkpoint N]`, shows the newest lines `latestOwnChat` still returns, and points at the words search for the rest. +- `deriveMessages` is called optionally (`agent?.session?.deriveMessages?.()`), because the bench Sessions in `core/test/group-work.test.js` have none and a hard call would throw instead of asserting. The ended-part handoff note and `Your own chat has no part N.` are unchanged. +- Covered by `core/test/group-work.test.js` beside the `#1906` reads. + ### One row per event in the chat index (#2711) - Two indexers over one home each read a part's mark before either committed, so both inserted the same events and every message came back twice — the doubling then lived in the file, because a later catch-up starts from the mark the last writer left. `chat-index.js` now reads `marks.through` **inside** the `BEGIN IMMEDIATE` transaction that inserts the rows, so a second writer's read sees the first one's commit; the transaction keeps its old size (one part's catch-up), and the rows and the mark move together, so an interrupted pass cannot leave the mark ahead of the index. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1c5907846..6994faec3 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -11851,7 +11851,7 @@ diff --git a/test/group-work.test.js b/test/group-work.test.js const text = message => message.content.map(block => block.text ?? '').join('\n') const kinds = result => result.replies.map(reply => [reply.kind ?? 'message', reply.name]) -@@ -287,3 +287,275 @@ test('members still at work show in the team view and in the prompts of the othe +@@ -287,3 +287,314 @@ test('members still at work show in the team view and in the prompts of the othe host.finish('gc', '没问题。') await settle(() => result() !== undefined) }) @@ -12127,6 +12127,45 @@ diff --git a/test/group-work.test.js b/test/group-work.test.js + assert.equal(shownLines(capOut).length, 12) + assert.doesNotMatch(capOut, /newest 12 of/) +}) ++ ++// #2774: a checkpoint condenses a part's older messages in place, so the current part is still ++// current but no longer in context. The part read says which checkpoint replaced it and where ++// the rest can still be read word for word. ++const checkpointMessages = number => [ ++ { role: 'user', content: [{ type: 'text', text: `[Checkpoint ${number}]` }, { type: 'text', text: `\n\n\n[Checkpoint ${number}]\n- Done: outline\n` }] }, ++] ++ ++test('a current part condensed by a checkpoint is not called "in your context" (#2774)', async (t) => { ++ const history = { b: [chatLine(0, 'first line'), chatLine(1, 'second line'), chatLine(2, 'the url is https://example.test/xyz')] } ++ const host = await start(t, undefined, history) ++ host.agentOf('b').session.deriveMessages = () => checkpointMessages(1) ++ const out = await host.tool('read_own_chat', { part: 1 }, 'b') ++ assert.doesNotMatch(out, /it is in your context/, `still claims context: ${out}`) ++ assert.match(out, /Checkpoint 1/) ++ assert.match(out, /words/) ++ assert.match(out, /the url is https:\/\/example\.test\/xyz/, 'the newest lines are still shown') ++}) ++ ++test('a current part with no checkpoint, and an ended or missing part, answer as before (#2774)', async (t) => { ++ const history = { b: [chatLine(0, 'first line'), chatLine(1, 'second line')] } ++ const host = await start(t, undefined, history) ++ // This bench's Sessions have no deriveMessages: the check must stay optional, not throw. ++ assert.equal(await host.tool('read_own_chat', { part: 1 }, 'b'), 'Part 1 is the current one; it is in your context.') ++ host.rt.state.bots.b.segments = [{ sessionId: 'p1', endedAt: 2_000 }] ++ const ended = await host.tool('read_own_chat', { part: 1 }, 'b') ++ assert.match(ended, /^Part 1 of your own chat \(/) ++ assert.match(ended, /The handoff note that closed it:/) ++ assert.match(await host.tool('read_own_chat', { part: 9 }, 'b'), /^Your own chat has no part 9\./) ++}) ++ ++// A guard, not a red-first case: the Session's deriveMessages is only a hint, so one that throws ++// must leave the read working and answering what it answered before #2774, never fail the tool. ++test('a Session whose deriveMessages throws still reads the current part (#2774)', async (t) => { ++ const history = { b: [chatLine(0, 'first line'), chatLine(1, 'second line')] } ++ const host = await start(t, undefined, history) ++ host.agentOf('b').session.deriveMessages = () => { throw new Error('session is gone') } ++ assert.equal(await host.tool('read_own_chat', { part: 1 }, 'b'), 'Part 1 is the current one; it is in your context.') ++}) diff --git a/test/history.test.js b/test/history.test.js --- a/test/history.test.js +++ b/test/history.test.js diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 92a14cebc..3909a874e 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1052,7 +1052,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,21 +7,52 @@ +@@ -7,21 +7,53 @@ // 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 @@ -1087,6 +1087,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js +import { createRoomParticipants } from './room-participant.js' +import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine, setRoomLineRedactor } from './room-context.js' +import { workDispatch } from './work-dispatch.js' ++import { checkpointCount } from './steward.js' +import { retiredBot } from './store.js' + +// #1253: how long an identical claim line keeps a card quiet after it was posted. A claim is a @@ -1591,7 +1592,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (by === undefined) delete called.by else called.by = by round.slots.splice(next, 0, called) -@@ -285,34 +285,204 @@ export function install(rt) { +@@ -285,23 +285,185 @@ export function install(rt) { // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { @@ -1644,11 +1645,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js - // The user spoke in this room, so Bots may start their own rounds again. - room.botRounds = 0 + // The user spoke in this room, so late replies may be answered again. - room.lateRounds = 0 - void rt.save().catch(() => {}) - return round.slots.length - before - } - ++ room.lateRounds = 0 ++ void rt.save().catch(() => {}) ++ return round.slots.length - before ++ } ++ + // One line that reaches the round while a member speaks (#1267). It is logged at once — the room + // shows it when it is sent, not at the next slot — and `notePostedLine` keeps the next relay from + // logging it a second time. Its pending copy leaves the relay's queue, because this round answers @@ -1661,8 +1662,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js + if (at !== -1) rt.roomInbox.set(room.id, queued.toSpliced(at, 1)) + round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) + // The user spoke in this room, so late replies may be answered again. -+ room.lateRounds = 0 -+ void rt.save().catch(() => {}) + room.lateRounds = 0 + void rt.save().catch(() => {}) + // The one line that ends work in flight (#1267 B): the slot is superseded, its turn is cancelled, + // and nothing it says when it stops is posted. The line stays in the Session's queue, so the head + // drain opens it as a fresh line (admin first) for the room to answer. @@ -1782,11 +1783,10 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) + callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped, call.text) + } -+ return round.slots.length - before -+ } -+ - async function runRound(room, round, signal, agent) { - let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) + return round.slots.length - before + } + +@@ -310,9 +310,17 @@ export function install(rt) { const turns = new Map() // The inbox check keeps a message sent during the last speaker's turn inside // this round: it drains at the loop's head even once the slots ran out. @@ -2063,7 +2063,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const parts = rt.partsOf(botId) const number = Number.isInteger(args.part) ? args.part : undefined if (room !== undefined && member !== undefined) { -@@ -507,6 +507,11 @@ export function install(rt) { +@@ -507,23 +507,49 @@ export function install(rt) { const found = await rt.aroundOwnChat(botId, around) if (found.error) return `${found.error}${partsNote}` if (found.lines.length === 0) return `Nothing was said around ${around}.${partsNote}` @@ -2075,7 +2075,28 @@ diff --git a/src/host/groups.js b/src/host/groups.js const heading = found.exact ? `around ${around}` : `with no message at ${around}; the nearest ones` return `Your own chat ${heading} (oldest first):\n${found.lines.join('\n')}${partsNote}` } -@@ -517,13 +517,17 @@ export function install(rt) { + if (query === '' && number !== undefined) { + const part = parts[number - 1] + if (part === undefined) return `Your own chat has no part ${number}.${partsNote}` +- if (part.endedAt === undefined) return `Part ${number} is the current one; it is in your context.${partsNote}` ++ if (part.endedAt === undefined) { ++ // A checkpoint replaces the part's older messages in place (#2774): the part is still ++ // the current one, but most of it left the context, so calling it context is false. ++ // A Session without deriveMessages (the test benches) and one whose deriveMessages ++ // throws both read as "no checkpoint": a read that used to answer keeps answering, ++ // instead of turning a broken Session into a failed tool call. ++ const agent = await rt.resolveAgent(part.sessionId).catch(() => undefined) ++ let checkpoints = 0 ++ try { ++ checkpoints = checkpointCount(agent?.session?.deriveMessages?.() ?? []) ++ } catch { ++ // The count is a message hint only, so a Session that cannot report its messages ++ // keeps the unchanged sentence rather than failing this read. ++ } ++ if (checkpoints === 0) return `Part ${number} is the current one; it is in your context.${partsNote}` ++ const { lines } = await rt.latestOwnChat(botId, { askingId: id }) ++ return `Part ${number} is the current one, but its earlier messages are condensed into [Checkpoint ${checkpoints}], so only its newest messages are still in your context:\n${lines.join('\n')}\nSearch it with words to read the rest word for word.${partsNote}` ++ } return `Part ${number} of your own chat (${rt.clock(part.startedAt)} – ${rt.clock(part.endedAt)}). The handoff note that closed it:\n${rt.noteText(part) ?? '(none: the part ended on a context overflow)'}${partsNote}` } if (query === '') {