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/1698-busy-ack-fresh-line.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The chat no longer acknowledges the message that starts a turn as if it had arrived while the Bot was busy: an idle conversation now answers with no generated "on it" line and no extra model call, while a line that really lands during a running turn keeps its one acknowledgment ([#1698](https://github.com/ThinkFlowLab/FlyBot/issues/1698)).
22 changes: 20 additions & 2 deletions core/src/host/busy-ack.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export function install(rt) {
// (#1684), so the memory has to outlive it.
/** @type {Map<string, { exact: Set<string>, bits: Uint32Array, folded: number, rebuilds: number }>} */
const ledgers = new Map()
// Sessions whose newest turn has not claimed its first user line yet. DSH appends `turn/start`
// before the step's `user/message` (agent-loop `index.js:943` and `:1061`), so the line that
// opens a turn is claimed while `turnRunning` is already true and looks exactly like a line that
// landed on a busy Bot. The first claim after `turn/start` is that opening line, never a busy
// arrival, so it creates no ack (#1698).
const openingLine = new Set()
let counter = 0

const turnRunning = sessionId => rt.live?.get(sessionId)?.turn !== undefined
Expand Down Expand Up @@ -274,6 +280,10 @@ export function install(rt) {
ctx.on('session/event', (session, event) => {
const id = session?.id
if (id === undefined || event?.type === undefined) return
if (event.type === 'turn/start') {
openingLine.add(id)
return
}
// DSH queues a person's line as `agent/inbox/spliced` the moment it arrives; its
// `user/message` follows at the next step start, when the line is claimed. The splice is
// the arrival, so the ack starts here and the claim below finds it already made (#1678).
Expand All @@ -300,6 +310,9 @@ export function install(rt) {
return
}
if (event.type === 'user/message') {
// The claim of the line that opened this turn: it is not a line that landed on a busy Bot,
// even though `turnRunning` is already true here (#1698).
if (openingLine.delete(id)) return
const source = event.data?.source
const room = roomLine(id, source)
const own = shouldNoteBusyAck({ sourceKind: source?.kind, turnRunning: turnRunning(id), ownChat: ownChat(id) })
Expand All @@ -314,13 +327,18 @@ export function install(rt) {
if (textOf(event.data?.message?.content).trim() !== '') clear(id)
return
}
if (event.type === 'turn/end') clear(id)
if (event.type === 'turn/end') {
openingLine.delete(id)
clear(id)
}
})

ctx.on('agent/status', ({ agent, status }) => {
if (status !== 'idle') return
const id = agent?.session?.id ?? agent?.id
if (typeof id === 'string') clear(id)
if (typeof id !== 'string') return
openingLine.delete(id)
clear(id)
})

Object.assign(rt, {
Expand Down
33 changes: 33 additions & 0 deletions core/test/busy-ack-ledger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,39 @@ test('the claim of a next-turn line after turn/end adds no second call or senten
assert.deepEqual(Object.keys(view), [], 'a claim of an acked line does not reopen the ack it ended with the turn')
})

test('the line that opens a turn is not acknowledged as a busy arrival (#1698)', async (t) => {
const h = await host(t)
const opener = personLine('m-open', 'please read the AGENTS file')

// The real order on an idle Session: the line is queued, the host opens the turn, and only then
// is the line claimed. `turn/start` is appended before the step's `user/message` (agent-loop
// `index.js:943` then `:1061`), so the line that started the turn is judged busy by the claim.
h.emit('a', 'agent/inbox/spliced', spliced([opener]).data)
h.running('a', 1)
h.emit('a', 'turn/start', { turn: 1 })
h.emit('a', 'user/message', claim(opener).data)
await h.rt.busyAckReady('a')

assert.equal(h.llm.calls.length, 0, 'the line that opened the turn costs no ack call')
assert.deepEqual(h.rt.busyAcksView(), {}, 'and publishes no ack entry')
assert.equal(h.rt.busyAckLedgerSize('a'), 0, 'the opening line is not remembered as acked')

// A line that really lands while that turn runs is still acknowledged once, at its arrival.
const busy = personLine('m-busy', 'and then the cover image')
h.emit('a', 'agent/inbox/spliced', spliced([busy]).data)
assert.equal(await h.rt.busyAckReady('a'), 'ACK:and then the cover image')
assert.equal(h.llm.calls.length, 1)

// Its claim opens the next turn and adds no second call: the line keeps its one ack.
h.emit('a', 'turn/end', { status: 'completed' })
h.running('a', 2)
h.emit('a', 'turn/start', { turn: 2 })
h.emit('a', 'user/message', claim(busy).data)
await h.rt.busyAckReady('a')
assert.equal(h.llm.calls.length, 1, 'the claim of an acked line adds no call')
assert.deepEqual(h.rt.busyAcksView(), {}, 'and does not reopen the entry the turn ended')
})

test('two lines interleaved across a turn boundary cost two calls, and their claims add none (#1684)', async (t) => {
const h = await host(t)
h.running('a')
Expand Down
22 changes: 16 additions & 6 deletions core/test/busy-ack.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,23 +209,31 @@ test('a room line steered into a running member\'s Session notes one ack for tha
const host = await roomHost(t)
host.rt.live.set('gb', { turn: 1, tool: 'bash' })
host.emit('gb', 'turn/start', { turn: 1 })
host.emit('gb', 'user/message', roomSay('帮我把周报再改一下').data)
// groups.js relays a mid-line as a steer, so the line reaches the running member as a
// `next-step` splice: the arrival is the ack, and the claim at the next step adds nothing (#1698).
const line = { ...roomSay('帮我把周报再改一下').data, id: 'm-1' }
host.emit('gb', 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [line] })
const acks = host.rt.busyAcksView()
assert.deepEqual(Object.keys(acks), ['gb'])
assert.equal(acks.gb.text, '')
assert.equal(await settleAck(host.rt, 'gb'), 'ACK:帮我把周报再改一下')
assert.doesNotMatch(host.llm.calls[0].messages[0].content[0].text, /Group chat/)
// Coalesced: a second line inside the window keeps the one ack.
const at = acks.gb.at
host.emit('gb', 'user/message', roomSay('again').data)
host.emit('gb', 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [{ ...roomSay('again').data, id: 'm-2' }] })
assert.deepEqual(Object.keys(host.rt.busyAcksView()), ['gb'])
assert.equal(host.rt.busyAcksView().gb.text, 'ACK:帮我把周报再改一下')
assert.ok(host.rt.busyAcksView().gb.at >= at)
assert.equal(host.llm.calls.length, 1, 'two lines inside the window keep one call')
// The claim of the line that opened the turn is not a second line.
host.emit('gb', 'user/message', line)
await settleAck(host.rt, 'gb')
assert.equal(host.llm.calls.length, 1, 'the claim of an acked line adds no call')
// No ack survives the turn.
host.emit('gb', 'turn/end', { turn: 1, reason: { kind: 'completed' } })
assert.deepEqual(host.rt.busyAcksView(), {})
host.rt.live.set('gb', { turn: 2 })
host.emit('gb', 'user/message', roomSay('还在吗').data)
host.emit('gb', 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [{ ...roomSay('还在吗').data, id: 'm-3' }] })
assert.ok(host.rt.busyAcksView().gb)
host.fire('agent/status', { agent: { session: { id: 'gb' } }, status: 'idle' })
assert.deepEqual(host.rt.busyAcksView(), {})
Expand All @@ -251,7 +259,7 @@ test('the room view names a running member before its line lands, then carries t
// The member's turn is already running while the person's line is still being composed:
// an empty text, which the client draws as the pending blinking indicator, no sentence.
assert.deepEqual(host.rt.roomBusyAcksView({ b: 'gb' }), { b: { text: '', at: 0 } })
host.emit('gb', 'user/message', roomSay('帮我把周报再改一下').data)
host.emit('gb', 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [{ ...roomSay('帮我把周报再改一下').data, id: 'm-1' }] })
await settleAck(host.rt, 'gb')
assert.equal(host.rt.roomBusyAcksView({ b: 'gb' }).b.text, 'ACK:帮我把周报再改一下')
// No running turn, no entry; an empty member map stays empty.
Expand All @@ -262,15 +270,17 @@ test('the room view names a running member before its line lands, then carries t

test('the own-chat ack is unchanged beside the room path (#1646)', async (t) => {
const host = await roomHost(t)
// A line that lands while the turn runs, at its arrival as DSH queues it (#1678).
host.rt.live.set('b', { turn: 1, tool: 'bash' })
host.emit('b', 'turn/start', { turn: 1 })
host.emit('b', 'user/message', say('check the logs').data)
host.emit('b', 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [{ ...say('check the logs').data, id: 'm-own' }] })
const acks = host.rt.busyAcksView()
assert.deepEqual(Object.keys(acks), ['b'])
assert.equal(await settleAck(host.rt, 'b'), 'ACK:check the logs')
// The same Session id space: a room line in the member's group Session is its own key.
host.rt.live.set('gb', { turn: 1 })
host.emit('gb', 'turn/start', { turn: 1 })
host.emit('gb', 'user/message', roomSay('帮我看日志').data)
host.emit('gb', 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [{ ...roomSay('帮我看日志').data, id: 'm-room' }] })
await settleAck(host.rt, 'gb')
assert.deepEqual(Object.keys(host.rt.busyAcksView()).sort(), ['b', 'gb'])
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi
- A friend's line in a Bot's own chat (#420): `deliver()` (`noteFriendLine`) stamps the receiving Bot with `friendLineAt` (published in the roster, so the save moves the revision) and keeps the senders seen per Bot in `state.remoteContacts[botId][handle]` (host only, capped at 200, dropped with the Bot). A sender's first line carries `source.remote.firstContact`. Group lines, and re-deliveries (`deferred`, `resumed`, `retried`, `resent`), stamp nothing. Covered by `core/test/flybot.test.js`.
## Busy-send acknowledgment (#1584)

When a person's line reaches a Bot's own chat while that Session already has a live turn, the host records a short spoken acknowledgment on the live payload (`busyAcks`) — at the line's arrival, which DSH appends as `agent/inbox/spliced` whose `inserted[].source.kind` is `user`; the line's `user/message` follows only at the next step's start, when it is claimed, which production logs put up to 768 s later, so the claim is the fallback for a splice that was not seen. The line's own message id is what keeps one line from earning a second ack and a second model call: the host keeps a per-Session ledger of the ids it has acknowledged, beside the ack entry rather than inside it, because the entry dies at `turn/end` and the claim of a `next-turn` line always lands after it ([#1684](https://github.com/ThinkFlowLab/FlyBot/issues/1684)). The ledger is bounded on both axes. Per Session it keeps `LINE_IDS_KEPT` ids exactly, folds anything older into a fixed-size 1 KiB filter (three salted positions per id), and rebuilds that filter from the exact set every `LEDGER_FILTER_REBUILD_AFTER` folds — a filter that only ever filled would answer "acked" for 3.6% of brand-new lines after 1000 lines and 97.9% after 12000, which silences the ack again, so the guarantee is the last 16 lines exactly plus under 1% worst-case false positives on older ones, and a pruned id still answers "already acked" and cannot resurrect a line, while a stale claim finds no ack entry to reopen ([#1678](https://github.com/ThinkFlowLab/FlyBot/issues/1678)). The Session map is bounded too (`LEDGER_SESSIONS_KEPT` Sessions, the one that wrote longest ago dropped), so an archived Session cannot hold its memory for the life of the process ([#1684](https://github.com/ThinkFlowLab/FlyBot/issues/1684)). An own-chat `next-step` splice — a steer, admitted at the running turn's next step — is not ack'd. A person's line relayed into a running member's group Session (`role: 'room'`, no Bot sender) earns the same one, keyed by that member Session and published per member on the room as `busyAcks` so the room draws it on the member's row ([#1646](https://github.com/ThinkFlowLab/FlyBot/issues/1646)); a mid-line there is relayed as a steer (`groups.js` `steer: true`), so it is spliced for `next-step`, and the member's row is acknowledged at arrival like an own-chat line rather than waiting for a claim ([#1684](https://github.com/ThinkFlowLab/FlyBot/issues/1684)). The line is written by one short model call on the host ([#1665](https://github.com/ThinkFlowLab/FlyBot/issues/1665)), replacing the templates of [#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629) that quoted a clipped piece of the message inside a fixed phrase: the ack is published at once with an empty text (the client draws the avatar with blinking dots), then a separate call — `config.flybot.modelFor['busy-ack']` when set, else the chat's own model, reasoning off when the model offers it, `ACK_MAX_TOKENS` output — is asked for one natural sentence in the message's language that shows what the new message is about and what the turn is doing (its running tool and the request it works on), with no quoting and no stock phrases. A hard `ACK_TIMEOUT_MS` (3 s) bounds it; a timeout, an error, an empty or overlong answer, or no model route leaves the text empty, so no sentence is ever sent and the dots clear with the turn. A late answer never revives a cleared ack. The client draws it as one bot bubble (and also from a local pending queue/steer submission) so the chat is not silent while a blocking job wait holds the next step. Related but separate: [#1509](https://github.com/ThinkFlowLab/FlyBot/issues/1509) (Steer does not abort a tool wait). Host: `core/src/host/busy-ack.js`. Client: `core/src/client/busy-ack.js`. Tests: `core/test/busy-ack.test.js`, `core/test/busy-ack-arrival.test.js`, `core/test/busy-ack-ledger.test.js`, `core/test/busy-ack-client.test.js`.
When a person's line reaches a Bot's own chat while that Session already has a live turn, the host records a short spoken acknowledgment on the live payload (`busyAcks`) — at the line's arrival, which DSH appends as `agent/inbox/spliced` whose `inserted[].source.kind` is `user`; the line's `user/message` follows only at the next step's start, when it is claimed, which production logs put up to 768 s later, so the claim is the fallback for a splice that was not seen — except for the line that opens a turn: DSH appends `turn/start` before the step's `user/message` (agent-loop `index.js:943` then `:1061`), so that line is claimed while the Session already reads as running, and the host skips the first claim after `turn/start` instead of acknowledging a message that started the turn ([#1698](https://github.com/ThinkFlowLab/FlyBot/issues/1698)). The line's own message id is what keeps one line from earning a second ack and a second model call: the host keeps a per-Session ledger of the ids it has acknowledged, beside the ack entry rather than inside it, because the entry dies at `turn/end` and the claim of a `next-turn` line always lands after it ([#1684](https://github.com/ThinkFlowLab/FlyBot/issues/1684)). The ledger is bounded on both axes. Per Session it keeps `LINE_IDS_KEPT` ids exactly, folds anything older into a fixed-size 1 KiB filter (three salted positions per id), and rebuilds that filter from the exact set every `LEDGER_FILTER_REBUILD_AFTER` folds — a filter that only ever filled would answer "acked" for 3.6% of brand-new lines after 1000 lines and 97.9% after 12000, which silences the ack again, so the guarantee is the last 16 lines exactly plus under 1% worst-case false positives on older ones, and a pruned id still answers "already acked" and cannot resurrect a line, while a stale claim finds no ack entry to reopen ([#1678](https://github.com/ThinkFlowLab/FlyBot/issues/1678)). The Session map is bounded too (`LEDGER_SESSIONS_KEPT` Sessions, the one that wrote longest ago dropped), so an archived Session cannot hold its memory for the life of the process ([#1684](https://github.com/ThinkFlowLab/FlyBot/issues/1684)). An own-chat `next-step` splice — a steer, admitted at the running turn's next step — is not ack'd. A person's line relayed into a running member's group Session (`role: 'room'`, no Bot sender) earns the same one, keyed by that member Session and published per member on the room as `busyAcks` so the room draws it on the member's row ([#1646](https://github.com/ThinkFlowLab/FlyBot/issues/1646)); a mid-line there is relayed as a steer (`groups.js` `steer: true`), so it is spliced for `next-step`, and the member's row is acknowledged at arrival like an own-chat line rather than waiting for a claim ([#1684](https://github.com/ThinkFlowLab/FlyBot/issues/1684)). The line is written by one short model call on the host ([#1665](https://github.com/ThinkFlowLab/FlyBot/issues/1665)), replacing the templates of [#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629) that quoted a clipped piece of the message inside a fixed phrase: the ack is published at once with an empty text (the client draws the avatar with blinking dots), then a separate call — `config.flybot.modelFor['busy-ack']` when set, else the chat's own model, reasoning off when the model offers it, `ACK_MAX_TOKENS` output — is asked for one natural sentence in the message's language that shows what the new message is about and what the turn is doing (its running tool and the request it works on), with no quoting and no stock phrases. A hard `ACK_TIMEOUT_MS` (3 s) bounds it; a timeout, an error, an empty or overlong answer, or no model route leaves the text empty, so no sentence is ever sent and the dots clear with the turn. A late answer never revives a cleared ack. The client draws it as one bot bubble (and also from a local pending queue/steer submission) so the chat is not silent while a blocking job wait holds the next step. Related but separate: [#1509](https://github.com/ThinkFlowLab/FlyBot/issues/1509) (Steer does not abort a tool wait). Host: `core/src/host/busy-ack.js`. Client: `core/src/client/busy-ack.js`. Tests: `core/test/busy-ack.test.js`, `core/test/busy-ack-arrival.test.js`, `core/test/busy-ack-ledger.test.js`, `core/test/busy-ack-client.test.js`.

## Steering interrupt: a wait over 60s yields to a human send (#1509, #1666)

Expand Down
Loading
Loading