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/1127-group-session-model.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot's existing group sessions follow a model change, including when the record already says that model is applied, so a turn no longer asks a provider the Bot was moved off (#1127).
58 changes: 56 additions & 2 deletions core/src/host/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,67 @@ export function install(rt) {

const sameSelection = (a, b) => a.provider === b.provider && a.model === b.model && a.reasoningEffort === b.reasoningEffort

// The provider/model the Session last requested, or null when it has not requested.
// undefined means the route cannot be read, so the record is left alone (#289).
async function liveRoute(sessionId) {
if (typeof rt.resolveAgent !== 'function') return undefined
let agent
try {
agent = await rt.resolveAgent(sessionId)
} catch {
// A Session that cannot be opened has no route to compare.
return undefined
}
const read = agent?.session?.requestHeader
if (typeof read !== 'function') return undefined
let header
try {
header = read()
} catch {
// A header that cannot be read is not evidence of a sticky route.
return undefined
}
const config = header?.config
if (!config?.provider || !config?.model) return null
return { provider: config.provider, model: config.model }
}

// The record can say the model is applied while a group Session still requests the
// previous provider (#1127). Re-select that Session. A Session we cannot read is
// skipped, so a settled Bot is not selected again on every refresh (#289).
async function rebindSessionModel(bot, sessionId) {
const ref = await desiredModel(bot)
if (ref === undefined || sessionId === undefined) return
const live = await liveRoute(sessionId)
if (live === undefined) return
const route = splitRef(ref)
if (live !== null && live.provider === route.provider && live.model === route.model) return
const effort = await desiredEffort(bot, ref)
try {
await selectSessionModel(sessionId, { ...route, ...(effort === undefined ? {} : { reasoningEffort: effort }) })
} catch (error) {
bot.modelStale = true
rt.warn('ds-bot: model for %s in a group not applied: %s', bot.name, error)
await rt.save?.()
}
}

async function rebindGroupSessions(bot) {
for (const sessionId of rt.groupSessionsOf(bot.id)) await rebindSessionModel(bot, sessionId)
}

// `force` re-selects even when the record says it is applied: the composer's own
// model picker can move the Session without telling this plugin.
async function applyModel(bot, { force = false } = {}) {
const ref = await desiredModel(bot)
if (ref === undefined) return ref
const effort = await desiredEffort(bot, ref)
if (!force && !bot.modelStale && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) return ref
// The own chat stays as recorded. Group Sessions still on another route are
// re-selected here, and again before their next turn (#1127).
if (!force && !bot.modelStale && bot.appliedModel === ref && (bot.appliedEffort ?? undefined) === effort) {
await rebindGroupSessions(bot)
return ref
}
const route = { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) }
await selectSessionModel(rt.chatOf(bot.id), route)
// A group Session that did not take the model keeps the Bot "stale", so the next apply (a model
Expand Down Expand Up @@ -274,6 +328,6 @@ export function install(rt) {
testModel,
catalog, cachedCatalog: () => catalogCache.value, cachedDefault: () => pickDefault(catalogCache.value),
defaultRef, defaultModel, resolveModel, desiredModel, reasoningOf, resolveEffort, desiredEffort,
selectSessionModel, applyModel, waitForModel, syncModels,
selectSessionModel, applyModel, rebindSessionModel, waitForModel, syncModels,
})
}
25 changes: 20 additions & 5 deletions core/src/host/parts.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ export function canonicalHeadings(note, names) {
return lines.join('\n')
}

// The model a fresh part starts on. The Bot's own model wins. The previous request
// is only a fallback when the Bot has none, so a sticky group route is not copied
// onto the next part (#1127).
export function freshPartModel(header, desiredRef) {
if (desiredRef !== undefined) return { ref: desiredRef }
if (header?.provider && header?.model) {
return {
ref: `${header.provider}/${header.model}`,
...(header.reasoningEffort === undefined ? {} : { effort: header.reasoningEffort }),
}
}
return undefined
}

export function install(rt) {
const { ctx, config, state } = rt

Expand Down Expand Up @@ -813,11 +827,12 @@ export function install(rt) {
const dir = rt.workDirOf(job)
const intended = dir !== undefined && await rt.isFolder(dir) ? dir : rt.teamFolder()
const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name, { dir })
const route = session.requestHeader()?.config
const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
if (ref !== undefined) {
const effort = route?.reasoningEffort ?? await rt.desiredEffort(bot, ref)
await rt.selectSessionModel(freshId, { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) })
// The Bot's model wins over the previous request. Copying that header is what
// kept a group Session on a provider the Bot had already left (#1127).
const picked = freshPartModel(session.requestHeader()?.config, await rt.desiredModel(bot))
if (picked !== undefined) {
const effort = picked.effort ?? await rt.desiredEffort(bot, picked.ref)
await rt.selectSessionModel(freshId, { ...splitRef(picked.ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) })
.catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error))
}
await rt.applyPermission(bot, freshId)
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/room-participant.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export function createDshRoomParticipant(rt) {
const known = room.sessions[member.id]
if (known !== undefined) {
// A failed model selection left this Bot stale (#289): try again before it speaks.
// A record that already matches can still hide a sticky route (#1127).
if (member.modelStale) await rt.applyModel(member).catch(error => rt.warn('ds-bot: model for %s in %s not repaired: %s', member.name, room.name, error))
else await rt.rebindSessionModel?.(member, known)
await rt.ensureCapabilities(known)
return { id: known, created: false }
}
Expand Down
11 changes: 11 additions & 0 deletions core/test/fresh-part-model.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { freshPartModel } from '../src/host/parts.js'

test('a fresh part takes the Bot\'s model, not the previous request\'s provider (#1127)', () => {
const sticky = { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'high' }
assert.deepEqual(freshPartModel(sticky, 'opencsitool/DeepSeek-V4.1-Flash-1017'), { ref: 'opencsitool/DeepSeek-V4.1-Flash-1017' })
assert.deepEqual(freshPartModel(sticky, undefined), { ref: 'deepseek-official/deepseek-flash', effort: 'high' })
assert.equal(freshPartModel(undefined, undefined), undefined)
assert.deepEqual(freshPartModel({ provider: 'deepseek-official', model: 'deepseek-flash' }, undefined), { ref: 'deepseek-official/deepseek-flash' })
})
68 changes: 68 additions & 0 deletions core/test/models.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,74 @@ test('a Bot model applies its thinking level, and only re-selects on a change',
assert.equal(bot.appliedEffort, undefined)
})

// The next request uses whatever selectModel last installed. A Session we cannot
// read stays out of this, which is what the #289 settled early-return relies on.
function routesOf(rt, headers) {
rt.resolveAgent = async (id) => ({
session: { requestHeader: () => (headers.has(id) ? { config: { ...headers.get(id) } } : undefined) },
})
const select = rt.ctx.sessionController.selectModel
rt.ctx.sessionController.selectModel = async (request) => {
const result = await select(request)
headers.set(request.sessionId, { provider: request.provider, model: request.model })
return result
}
}

test('a settled record still re-selects a group Session left on another provider (#1127)', async () => {
const next = 'opencsitool/DeepSeek-V4.1-Flash-1017'
const { rt, selected } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } })
const headers = new Map([
['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }],
['g2', { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' }],
['b1', { provider: 'deepseek-official', model: 'deepseek-flash' }],
['stranger', { provider: 'deepseek-official', model: 'deepseek-flash' }],
])
routesOf(rt, headers)
rt.groupSessionsOf = () => ['g1', 'g2']
const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next }
await rt.applyModel(bot)
assert.deepEqual(headers.get('g1'), { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' })
assert.deepEqual(selected.filter(item => item.startsWith('g1:')), ['g1:opencsitool/DeepSeek-V4.1-Flash-1017'])
// Already on the Bot's model: not selected again. The own chat and a stranger Session stay put.
assert.equal(selected.some(item => item.startsWith('g2:')), false)
assert.equal(selected.some(item => item.startsWith('b1:')), false)
assert.equal(selected.some(item => item.startsWith('stranger:')), false)
assert.deepEqual(headers.get('b1'), { provider: 'deepseek-official', model: 'deepseek-flash' })
assert.deepEqual(headers.get('stranger'), { provider: 'deepseek-official', model: 'deepseek-flash' })
assert.equal('modelStale' in bot, false)
const before = selected.length
await rt.applyModel(bot)
assert.equal(selected.length, before)
})

test('force-apply re-selects every group Session even when the record already matches (#1127)', async () => {
const next = 'opencsitool/DeepSeek-V4.1-Flash-1017'
const { rt, selected } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } })
rt.groupSessionsOf = () => ['g1']
const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next }
await rt.applyModel(bot, { force: true })
assert.deepEqual(selected.filter(item => item.startsWith('g1:')), ['g1:opencsitool/DeepSeek-V4.1-Flash-1017'])
assert.ok(selected.some(item => item.startsWith('b1:')))
})

test('a drifted group Session that refuses the model marks the Bot stale (#1127)', async () => {
const next = 'opencsitool/DeepSeek-V4.1-Flash-1017'
const { rt } = bench({ providers: { 'deepseek-official': ['deepseek-flash'], opencsitool: ['DeepSeek-V4.1-Flash-1017'] } })
const headers = new Map([['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }]])
routesOf(rt, headers)
const select = rt.ctx.sessionController.selectModel
rt.ctx.sessionController.selectModel = async (request) => {
if (request.sessionId === 'g1') throw new Error('no adapter')
return select(request)
}
rt.groupSessionsOf = () => ['g1']
const bot = { id: 'b1', name: 'Data', model: next, appliedModel: next }
await rt.applyModel(bot)
assert.equal(bot.modelStale, true)
assert.equal(bot.appliedModel, next)
})

test('a group Session that did not take the model keeps the Bot stale and is retried (#289)', async () => {
const { rt, selected } = bench()
let failing = true
Expand Down
61 changes: 61 additions & 0 deletions core/test/room-participant.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { test } from 'node:test'
import { getEventListeners } from 'node:events'
import { createDshRoomParticipant } from '../src/host/room-participant.js'
import { install as installModels } from '../src/host/models.js'
import { ROOM_WORK_GRACE_MS } from '../src/host/constants.js'

function fixture(t) {
Expand Down Expand Up @@ -31,6 +32,66 @@ function fixture(t) {
return { rt, driver, room, member, calls, controller, started, run, waiter }
}

test('a stale Bot still repairs through applyModel before it speaks (#289)', async (t) => {
const { rt, driver, room, member, calls } = fixture(t)
room.sessions = { writer: 's1' }
member.modelStale = true
rt.applyModel = async (bot) => { calls.push(['apply', bot.id]); delete bot.modelStale }
rt.rebindSessionModel = async () => { calls.push(['rebind']) }
assert.deepEqual(await driver.open({ room, member }), { id: 's1', created: false })
assert.deepEqual(calls.filter(call => call[0] === 'apply' || call[0] === 'rebind'), [['apply', 'writer']])
})

test('opening an existing group session re-selects a route the record already calls applied (#1127)', async (t) => {
const next = 'opencsitool/DeepSeek-V4.1-Flash-1017'
const headers = new Map([
['g1', { provider: 'deepseek-official', model: 'deepseek-flash' }],
['own', { provider: 'deepseek-official', model: 'deepseek-flash' }],
['stranger', { provider: 'deepseek-official', model: 'deepseek-flash' }],
])
const selected = []
let current = { provider: 'deepseek-official', model: 'deepseek-flash' }
const ctx = {
llm: {
listProviders: () => [{ id: 'deepseek-official' }, { id: 'opencsitool' }],
listModels: async (id) => [{ id: id === 'opencsitool' ? 'DeepSeek-V4.1-Flash-1017' : 'deepseek-flash' }],
resolveModelInfo: async () => ({}),
},
get: () => ({ currentSelection: () => ({ ...current }), saveSelection: async (nextSelection) => { current = { ...nextSelection } } }),
sessionController: {
selectModel: async (request) => {
selected.push(request)
headers.set(request.sessionId, { provider: request.provider, model: request.model })
},
},
}
const member = { id: 'data', name: 'Data', model: next, appliedModel: next }
const room = { id: 'ops', name: '研发组', seen: {}, sessions: { data: 'g1' } }
const rt = {
ctx, state: { bots: { data: member } }, warn() {}, save: async () => {},
chatOf: () => 'own', groupSessionsOf: () => ['g1'],
resolveAgent: async (id) => ({ session: { requestHeader: () => (headers.has(id) ? { config: { ...headers.get(id) } } : undefined) } }),
live: new Map(), groupOwners: new Map(), roomWaiters: new Map(),
createSession: async () => { throw new Error('the session already exists') },
ensureCapabilities: async () => {}, applyPermission: async () => {},
workDirOf: () => '/fixture',
}
installModels(rt)
const driver = createDshRoomParticipant(rt)
t.after(() => {})
assert.deepEqual(await driver.open({ room, member }), { id: 'g1', created: false })
assert.equal(selected.length, 1)
assert.equal(selected[0].sessionId, 'g1')
assert.equal(selected[0].provider, 'opencsitool')
assert.equal(selected[0].model, 'DeepSeek-V4.1-Flash-1017')
assert.deepEqual(headers.get('g1'), { provider: 'opencsitool', model: 'DeepSeek-V4.1-Flash-1017' })
assert.equal(headers.get('own').provider, 'deepseek-official', 'the own chat is not re-selected from a group open')
assert.equal(headers.get('stranger').provider, 'deepseek-official', 'a session that is not this Bot\'s group session stays')
assert.equal('modelStale' in member, false)
await driver.open({ room, member })
assert.equal(selected.length, 1, 'a session already on the Bot\'s model is not selected again')
})

test('DSH participant opens isolated room sessions and reuses persisted links', async t => {
const { rt, driver, room, member, calls } = fixture(t)
assert.deepEqual(await driver.open({ room, member }), { id: 's1', created: true })
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi
- New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`.
- Silent creation (#645): `createBot` and `create-room` send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: `new-chat.js` sends it to the new Bot as one user turn and the card carries no `brief`; `roster.js` turns a `brief` into `Why you exist: …` instructions only when a Bot created the Bot (`create_bot`, a tree child), where the brief is a parent's context. The `kickoff` role, the `[Bot team setup]` text and the group-created cue are gone (an older transcript's kickoff line is still skipped by `historyEntry`), and `create_bot` answers that the new Bot starts no turn. Covered by `core/test/roster.test.js`, `core/test/rounds.test.js` and `core/test/new-chat.test.js`.
- Every new Bot is announced (`bot.created`) through the `rt.createBot` wrapper, including the first Main Bot `bootstrap` creates on the first `state` request and a `duplicate-bot` copy. Creating those through the module-local `createBot` used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. Covered by `core/test/flybot.test.js`.
- Group Session models (#289): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. Covered by `core/test/models.test.js`.
- Group Session models (#289, #1127): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. A settled record (`appliedModel` matches and `modelStale` is unset) still re-selects a group Session whose last request used another provider or model, on that apply and before the Session's next turn; the Bot's own chat is left as recorded. A Session whose route cannot be read is left alone, so a settled Bot is not selected again on every refresh. A fresh part takes the Bot's model, not the previous request's route. Covered by `core/test/models.test.js`, `core/test/room-participant.test.js` and `core/test/fresh-part-model.test.js`.

### Face-to-face room lead (#476)

Expand Down
Loading
Loading