Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2774-part-checkpoint-context.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
20 changes: 19 additions & 1 deletion core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 === '') {
Expand Down
39 changes: 39 additions & 0 deletions core/test/group-work.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<compacted-summary>\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.')
})
6 changes: 6 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,12 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi
- A bare `#<seq>` 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.
Expand Down
41 changes: 40 additions & 1 deletion patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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<compacted-summary>\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
Expand Down
53 changes: 37 additions & 16 deletions patches/ds-bot/0012-room-membership.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}`
Expand All @@ -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 === '') {
Expand Down
Loading