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/913-report-addressed-silence.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A group round's closing report no longer folds a member that was addressed with `@` and never answered into the same sentence as a member that read the line and passed: the two now get separate sentences, so "he had nothing to add" can be told from "he was called and said nothing". A member the line did not name still appears under `Stayed silent:` ([#913](https://github.com/ThinkFlowLab/FlyBot/issues/913)).
22 changes: 18 additions & 4 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ export function install(rt) {
}
}
callMembers(round, turns, index - 1, speakers, note, ROOM_ADMIN_TURNS_EACH)
// The line names them, so the report says "no answer" rather than "stayed silent" (#913).
for (const slot of round.slots) if (slot.by === undefined && found.some(bot => bot.id === slot.id)) slot.addressed = true
}
// The user spoke in this room, so late replies may be answered again.
room.lateRounds = 0
Expand Down Expand Up @@ -617,8 +619,9 @@ export function install(rt) {
// this home's Bots to answer is a person's line, not an event (#462).
const opener = posts.findLast(post => !event(post) || friendAsks(post)) ?? posts.at(-1)
const references = posts.flatMap(post => post.references ?? [])
// A member a Bot's post calls by name works for that Bot.
const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found
// The members the opening line names: a Bot's post calls them to work for that Bot, a person's
// line addresses them. The report keeps that apart from a plain pass (#913).
const named = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? []).found
// A person's plain line (not @all) in a face-to-face room: the Bots decide who answers (#299). The
// owner's own line is never left unanswered; a friend's attested line is answered by their home too.
const route = routeFor(room, opener, startSeq)
Expand All @@ -632,7 +635,7 @@ export function install(rt) {
...(references.length > 0 ? { references, shown: [] } : {}),
slots: speakers.map(member => ({
id: member.id, name: member.name, status: 'waiting',
...(named.includes(member) ? { by: opener.botId } : {}),
...(named.includes(member) ? (opener.botId === undefined ? { addressed: true } : { by: opener.botId }) : {}),
})),
// One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop.
...(isPlainFaceLine(room, opener) ? { single: true } : {}),
Expand Down Expand Up @@ -998,11 +1001,22 @@ export function install(rt) {
const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`)
const waiting = round.replies.filter(reply => reply.kind === 'approval').map(reply => reply.name)
const working = round.replies.filter(reply => reply.kind === 'working').map(reply => reply.name)
// A turn that ended without a line: a pass, or no reply at all. A member the round's line named
// and that ended this way was addressed and did not answer — not the same thing as a member that
// read the line and had nothing to add (#913). `addressed` is the person's line, `by` the member
// that called it; the poster's own call is the "your @" one.
const silentTurn = slot => slot.status === 'passed' || slot.status === 'missed'
const mine = slot => slot.addressed === true || (slot.by !== undefined && slot.by === post.botId)
const addressed = round.slots.filter(slot => mine(slot) && silentTurn(slot)).map(slot => slot.name)
const called = round.slots.filter(slot => !mine(slot) && slot.by !== undefined && silentTurn(slot)).map(slot => slot.name)
const silent = round.passed.filter(name => !addressed.includes(name) && !called.includes(name))
const text = [
lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.',
working.length > 0 ? `Still working on it: ${working.join(', ')}. Each posts its result in the group when done, not to you; read_group_chat shows it.` : '',
waiting.length > 0 ? `Waiting for the user's approval: ${waiting.join(', ')}. What they say afterwards is posted in the group, not sent to you; read_group_chat shows it.` : '',
round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
silent.length > 0 ? `Stayed silent: ${silent.join(', ')}.` : '',
addressed.length > 0 ? `No answer to your @: ${addressed.join(', ')}.` : '',
called.length > 0 ? `Called, but no answer: ${called.join(', ')}.` : '',
round.failed.length > 0 ? `Could not answer because their model call failed: ${round.replies.filter(reply => reply.kind === 'failed').map(reply => `${reply.name} (${failureLine({ code: reply.code, status: reply.status, message: reply.text })})`).join(', ')}.` : '',
].filter(Boolean).join('\n\n')
// The replies are what the room's members said, so the report is the owner's request only when the
Expand Down
67 changes: 67 additions & 0 deletions core/test/rounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1603,3 +1603,70 @@ test('a face room\'s roster and routing rule stand in its section; a routed turn
}
assert.ok(turns.some(turn => turn.sessionId === hosted.sessions[writer]))
})

// #913: the closing report wrote a member the line called with `@` and that never answered into the
// same sentence as a member that read the line and answered [PASS], so the person could not tell "he
// had nothing to add" from "he was addressed and said nothing". The round already knows which of its
// slots the line addressed; the report now says so on its own sentence and leaves the rest where it was.
test('#913: a called member that never answers is reported apart from a member that merely stayed silent', async (t) => {
const { call, emit, compose, relay, answer, stop, sent } = start()
t.after(stop)
const room = await team(call, sent)
const chief = (await call('state', {})).value.mainBotId
const editor = (await call('create-bot', { name: 'Editor' })).value
await call('update-room', { id: room.id, add: [editor.id] })
sent.length = 0
// A Bot posts with report_back, so the round's closing report comes back to it. The post names
// nobody, so the room's order answers it: the Writer first, the Editor next.
const post = async (text) => emit(room.id, { type: 'user/message', data: { source: { kind: 'bot', role: 'post', senderSessionId: chief, senderName: 'Chief', reportBack: true }, content: [{ type: 'text', text }] } })
const reportAfter = async (at) => waitFor(() => sent.filter(entry => entry.message?.source?.role === 'report')[at])

// Round one: the Writer passes without being called; the Editor is called with `@` and passes too.
await post('please check the draft')
const running = relay(room.id, 'r913-1')
const first = await turnFor(sent)
await compose(room.id, '@Editor ping')
await answer(first.sessionId, first.message.source.exchangeId, '[PASS]', 1)
const second = await turnFor(sent, first)
assert.match(second.message.content[0].text, /the user mentioned you/)
await answer(second.sessionId, second.message.source.exchangeId, '[PASS]', 2)
const done = JSON.parse(await running)
assert.deepEqual(done.passed, ['Writer', 'Editor'], 'both turns ended without a line')
const report = await reportAfter(0)
assert.ok(report, 'the round reported back to the Bot that posted')
const text = report.message.content[0].text
assert.match(text, /Stayed silent: Writer\./, 'a pass nobody called stays in that sentence')
assert.match(text, /No answer to your @: Editor\./, 'a called member that did not answer gets its own sentence')
assert.doesNotMatch(text, /Stayed silent: [^.]*Editor/, 'the two outcomes are never written into one sentence')

// Round two: the same call, answered. No "no answer" sentence at all.
await post('one more look')
const running2 = relay(room.id, 'r913-2')
const third = await turnFor(sent, first, second)
await compose(room.id, '@Editor ping')
await answer(third.sessionId, third.message.source.exchangeId, '[PASS]', 3)
const fourth = await turnFor(sent, first, second, third)
assert.match(fourth.message.content[0].text, /the user mentioned you/)
await answer(fourth.sessionId, fourth.message.source.exchangeId, 'Here it is.', 4)
await running2
const text2 = (await reportAfter(1)).message.content[0].text
assert.match(text2, /Stayed silent: Writer\./)
assert.doesNotMatch(text2, /No answer to your @/, 'a called member that answered is not reported as missing')

// Round three: the called member's turn ends with no reply at all (missed, not a passing string).
// It lands on the same sentence as a pass, once, and never twice.
await post('last one')
const controller = new AbortController()
const running3 = relay(room.id, 'r913-3', controller.signal)
const fifth = await turnFor(sent, first, second, third, fourth)
await compose(room.id, '@Editor ping')
await answer(fifth.sessionId, fifth.message.source.exchangeId, '[PASS]', 5)
const sixth = await turnFor(sent, first, second, third, fourth, fifth)
assert.match(sixth.message.content[0].text, /the user mentioned you/)
controller.abort()
await running3.catch(() => {})
const text3 = (await reportAfter(2)).message.content[0].text
assert.match(text3, /No answer to your @: Editor\./, 'a turn that ended with no reply is reported the same way')
assert.equal((text3.match(/No answer to your @/g) ?? []).length, 1, 'reported once, not twice')
assert.match(text3, /Stayed silent: Writer\./)
})
4 changes: 4 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ A member's group Session reads the group's fixed rules from a system-prompt sect

`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`.

### A group round's closing report (#913)

When a Bot posts with `report_back`, `reportRound` sends it the round's closing report. A member the round's line named and that ended its turn without a line (a pass, or no reply at all) was addressed and did not answer, which is not the same thing as a member that read the line and had nothing to add: the first gets `No answer to your @: <names>.` (the poster's own line or a person's line named it; another member's call is `Called, but no answer: <names>.`), and only the rest stays under `Stayed silent:`. A slot keeps `addressed: true` when a person's line named it, and `by: <bot id>` when a Bot's post did, so the report reads the slots instead of `round.passed` alone. Nothing is re-called: the everyone-passed fallback (`fallbackDue`) keeps its single extra turn, and the member prompt is unchanged. Covered by `core/test/rounds.test.js`.

### Team state and access

- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \<inviter\>**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait.
Expand Down
Loading
Loading