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/410-inherit-creator-model.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot that the Main Bot (or a tree parent) creates without naming a model now starts on its creator's model and thinking level before its first request, so it no longer fails first on an unconfigured default model ([#410](https://github.com/ThinkFlowLab/FlyBot/issues/410)).
13 changes: 11 additions & 2 deletions core/src/host/roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,17 @@ export function install(rt) {
} else if (cleanName === '') throw new Error('A friend\'s Bot needs a name')
const cleanAvatarValue = cleanAvatar(avatar)
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
// A Bot made by another Bot (create_bot, a tree child) with no model named starts on its
// maker's effective model and thinking level (#410; the level resolved against that
// model, as applyModel does for the maker), set before its first request, so it never
// tries an unconfigured profile default first. A friend's Bot is never a maker here.
const maker = !model && !pickModel && !isRemote && createdBy ? rt.botOf(createdBy) : undefined
const inherited = maker && !maker.remote ? await rt.desiredModel(maker) : undefined
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : inherited ?? await rt.defaultModel()
const inheritedEffort = effort === undefined && inherited !== undefined ? maker.effort : undefined
const effortId = modelRef === undefined ? undefined
: effort !== undefined ? await rt.resolveEffort(modelRef, effort)
: inheritedEffort !== undefined ? await rt.resolveEffort(modelRef, inheritedEffort).catch(() => undefined) : undefined
const dir = workDir === undefined || workDir === null ? undefined : await rt.cleanWorkDir(workDir)
// Every Bot binds to the team's one owner (#47), resolved before the Session exists
// so a failure here leaves nothing to clean up.
Expand Down
2 changes: 1 addition & 1 deletion core/src/host/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export function install(rt) {
role: { type: 'string', required: true, description: 'Two-to-four character role label shown under the name' },
instructions: { type: 'string', required: true, description: 'What this Bot is responsible for and how it should work' },
brief: { type: 'string', description: 'Why the user wants it, passed to the new Bot as its first context' },
model: { type: 'string', description: 'Model the Bot runs on, as provider/model or a model id. Only when the user names one; otherwise the DSH default model.' },
model: { type: 'string', description: 'Model the Bot runs on, as provider/model or a model id. Only when the user names one; otherwise the new Bot starts on your own model.' },
work_dir: WORK_DIR_PARAM,
},
output,
Expand Down
26 changes: 26 additions & 0 deletions core/test/roster.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,32 @@ test('other Bots start on the default model and greet at once', async (t) => {
assert.deepEqual(sent.map(entry => entry.sessionId), [writer.id])
})

test('a Bot made by the Main Bot starts on the Main Bot\'s model and level before its first request (#410)', async (t) => {
const reasoning = { 'deepseek/deepseek-v4-pro': { reasoning: { efforts: [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }], defaultEffort: 'high' } } }
// The profile default is the first served model (flash); Chief runs on pro.
const { call, stop, sent, selected, ctx } = start(undefined, { models: ['deepseek-v4-flash', 'deepseek-v4-pro'], reasoning })
t.after(stop)
const chief = (await call('state', {})).value.mainBotId
await call('update-bot', { id: chief, model: 'deepseek/deepseek-v4-pro', effort: 'low' })
await until(() => sent.some(entry => entry.sessionId === chief))
await ctx.registeredTools.get('create_bot').execute({ name: 'Writer', role: 'Writer', instructions: 'Write.' }, { agent: { id: chief } })
const writer = (await call('state', {})).value.bots.find(entry => entry.name === 'Writer')
assert.equal(writer.model, 'deepseek/deepseek-v4-pro')
assert.equal(writer.effort, 'low')
await until(() => sent.some(entry => entry.sessionId === writer.id))
// No model selection for the new Bot ever names the profile default.
const own = selected.filter(request => request.sessionId === writer.id)
assert.ok(own.length > 0)
assert.ok(own.every(request => request.model === 'deepseek-v4-pro'), JSON.stringify(own))
// A model the Main Bot names still wins; a Bot the user makes keeps the default.
await ctx.registeredTools.get('create_bot').execute({ name: 'Editor', role: 'Editor', instructions: 'Edit.', model: 'deepseek/deepseek-v4-flash' }, { agent: { id: chief } })
const editor = (await call('state', {})).value.bots.find(entry => entry.name === 'Editor')
assert.equal(editor.model, 'deepseek/deepseek-v4-flash')
assert.equal(editor.effort, undefined)
const typed = (await call('create-bot', { name: 'Typist' })).value
assert.equal(typed.model, 'deepseek/deepseek-v4-flash')
})

test('a new name must be one "@" can call; a name saved before the rule still saves', async (t) => {
const { call, stop } = start(undefined, { models: ['deepseek-v4-flash'] })
t.after(stop)
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ credentials and team mode remain separate work under #55/#198.
- Another member's own face-room line arrives through `flybot.rooms.postHuman` (#180): a quiet `postToRoom` post carrying `source.human { authorId, name }`. `group_relay` turns it into a round reply `{ kind: 'user', other: true, name, authorId }` (not the grey `notice`), so it draws as a left-hand bubble from that person; it asks no Bot to answer. `postNotice` stays for system events (joined, left, started). Covered by `core/test/rounds.test.js`.
- `flybot.chatApprovals.changed()` saves the team (moving the revision) so `bot.pendingCount` reaches the browser at once when a hold arrives or is decided (#226). Covered by `core/test/flybot.test.js`.
- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested line routes at the opener. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off. The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`.
- 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), before the kickoff, 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`.
- 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`.

### Face-to-face participant look
Expand Down
26 changes: 23 additions & 3 deletions patches/ds-bot/0002-flybot-api-host.patch
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
state.workspaceId = workspace.id
await rt.save()
return workspace.id
@@ -58,16 +58,24 @@ export function install(rt) {
@@ -58,16 +58,33 @@ export function install(rt) {

// `pickModel` leaves the model to the user: the chat opens on a model card, and the
// greeting waits until a model is chosen. So does a Bot made when no model is served.
Expand All @@ -126,8 +126,19 @@ diff --git a/src/host/roster.js b/src/host/roster.js
+ } else if (cleanName === '') throw new Error('A friend\'s Bot needs a name')
const cleanAvatarValue = cleanAvatar(avatar)
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
- const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
- const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
+ // A Bot made by another Bot (create_bot, a tree child) with no model named starts on its
+ // maker's effective model and thinking level (#410; the level resolved against that
+ // model, as applyModel does for the maker), set before its first request, so it never
+ // tries an unconfigured profile default first. A friend's Bot is never a maker here.
+ const maker = !model && !pickModel && !isRemote && createdBy ? rt.botOf(createdBy) : undefined
+ const inherited = maker && !maker.remote ? await rt.desiredModel(maker) : undefined
+ const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : inherited ?? await rt.defaultModel()
+ const inheritedEffort = effort === undefined && inherited !== undefined ? maker.effort : undefined
+ const effortId = modelRef === undefined ? undefined
+ : effort !== undefined ? await rt.resolveEffort(modelRef, effort)
+ : inheritedEffort !== undefined ? await rt.resolveEffort(modelRef, inheritedEffort).catch(() => undefined) : undefined
const dir = workDir === undefined || workDir === null ? undefined : await rt.cleanWorkDir(workDir)
+ // Every Bot binds to the team's one owner (#47), resolved before the Session exists
+ // so a failure here leaves nothing to clean up.
Expand Down Expand Up @@ -829,6 +840,15 @@ diff --git a/src/host/tools.js b/src/host/tools.js
return `Stopped ${target.name} in ${running.map(whereOf).join(' and ')}. It is told why in a new turn, whose reply comes back to you.`
},
}))
@@ -175,7 +175,7 @@ export function install(rt) {
role: { type: 'string', required: true, description: 'Two-to-four character role label shown under the name' },
instructions: { type: 'string', required: true, description: 'What this Bot is responsible for and how it should work' },
brief: { type: 'string', description: 'Why the user wants it, passed to the new Bot as its first context' },
- model: { type: 'string', description: 'Model the Bot runs on, as provider/model or a model id. Only when the user names one; otherwise the DSH default model.' },
+ model: { type: 'string', description: 'Model the Bot runs on, as provider/model or a model id. Only when the user names one; otherwise the new Bot starts on your own model.' },
work_dir: WORK_DIR_PARAM,
},
output,
@@ -257,7 +257,7 @@ export function install(rt) {
}
return { bots, unknown }
Expand Down
33 changes: 33 additions & 0 deletions patches/ds-bot/0009-identity-schema.patch
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@ FlyBot patch 0009-identity-schema on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gen
diff --git a/test/roster.test.js b/test/roster.test.js
--- a/test/roster.test.js
+++ b/test/roster.test.js
@@ -119,6 +119,32 @@ test('other Bots start on the default model and greet at once', async (t) => {
assert.deepEqual(sent.map(entry => entry.sessionId), [writer.id])
})

+test('a Bot made by the Main Bot starts on the Main Bot\'s model and level before its first request (#410)', async (t) => {
+ const reasoning = { 'deepseek/deepseek-v4-pro': { reasoning: { efforts: [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }], defaultEffort: 'high' } } }
+ // The profile default is the first served model (flash); Chief runs on pro.
+ const { call, stop, sent, selected, ctx } = start(undefined, { models: ['deepseek-v4-flash', 'deepseek-v4-pro'], reasoning })
+ t.after(stop)
+ const chief = (await call('state', {})).value.mainBotId
+ await call('update-bot', { id: chief, model: 'deepseek/deepseek-v4-pro', effort: 'low' })
+ await until(() => sent.some(entry => entry.sessionId === chief))
+ await ctx.registeredTools.get('create_bot').execute({ name: 'Writer', role: 'Writer', instructions: 'Write.' }, { agent: { id: chief } })
+ const writer = (await call('state', {})).value.bots.find(entry => entry.name === 'Writer')
+ assert.equal(writer.model, 'deepseek/deepseek-v4-pro')
+ assert.equal(writer.effort, 'low')
+ await until(() => sent.some(entry => entry.sessionId === writer.id))
+ // No model selection for the new Bot ever names the profile default.
+ const own = selected.filter(request => request.sessionId === writer.id)
+ assert.ok(own.length > 0)
+ assert.ok(own.every(request => request.model === 'deepseek-v4-pro'), JSON.stringify(own))
+ // A model the Main Bot names still wins; a Bot the user makes keeps the default.
+ await ctx.registeredTools.get('create_bot').execute({ name: 'Editor', role: 'Editor', instructions: 'Edit.', model: 'deepseek/deepseek-v4-flash' }, { agent: { id: chief } })
+ const editor = (await call('state', {})).value.bots.find(entry => entry.name === 'Editor')
+ assert.equal(editor.model, 'deepseek/deepseek-v4-flash')
+ assert.equal(editor.effort, undefined)
+ const typed = (await call('create-bot', { name: 'Typist' })).value
+ assert.equal(typed.model, 'deepseek/deepseek-v4-flash')
+})
+
test('a new name must be one "@" can call; a name saved before the rule still saves', async (t) => {
const { call, stop } = start(undefined, { models: ['deepseek-v4-flash'] })
t.after(stop)
@@ -526,3 +526,34 @@ test('a write that fails at save time archives the Session it made', async (t) =
const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8'))
assert.equal(Object.values(saved.bots).some(bot => bot.name === 'Phantom'), false)
Expand Down
Loading