From b1eae950619265443cec45ab869fdaeb14626242 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:09:51 +0800 Subject: [PATCH] fix(workflow): a run Bot takes the profile model, not the starter's A run Bot started through run_workflow inherited the model of the Bot that started it: spawnAll wrote the caller into spec.createdBy, and the roster reads that field as "a Bot made this Bot", so a node with no model named started on the caller's model while the same graph started from the board, the canvas or the API took the profile default (#1582). The caller is a connector ceiling, not a parent: it already travels in cappedBy (runCaps names the caller and the graph's author), and capsOf reads that field. spawnAll now writes cappedBy only, the plugin passes cappedBy only, and the roster never treats an ephemeral Bot's createdBy as a maker, so a run record that still carries it keeps the profile default too. #1558's remote-caller refusal read createdBy, which would have made it dead code: remoteCaller now reads the same caps list, so a run started by a friend's Bot still reaches no connector and is still masked out of schemas(). Tests, the ds-bot patch mirrors and the subsystem docs move with it. Written-by: FlyBot Dev-1 --- changelog.d/1582.fixed.md | 1 + core/src/host/connector-access.js | 24 +++--- core/src/host/roster.js | 5 +- core/test/connector-access.test.js | 36 +++++---- core/test/runbot-model.test.js | 79 +++++++++++++++++++ docs/subsystems/core-host.md | 6 +- docs/subsystems/workflow.md | 2 +- patches/ds-bot/0002-flybot-api-host.patch | 7 +- .../ds-bot/files/src/host/connector-access.js | 24 +++--- .../files/test/connector-access.test.js | 36 +++++---- .../ds-bot/files/test/runbot-model.test.js | 79 +++++++++++++++++++ plugins/workflow/index.js | 5 +- plugins/workflow/src/ephemeral.js | 6 +- plugins/workflow/test/ephemeral.test.js | 17 ++-- 14 files changed, 252 insertions(+), 75 deletions(-) create mode 100644 changelog.d/1582.fixed.md create mode 100644 core/test/runbot-model.test.js create mode 100644 patches/ds-bot/files/test/runbot-model.test.js diff --git a/changelog.d/1582.fixed.md b/changelog.d/1582.fixed.md new file mode 100644 index 000000000..ac4a7af18 --- /dev/null +++ b/changelog.d/1582.fixed.md @@ -0,0 +1 @@ +A Workflow node that names no model now runs on the profile default whichever surface started the run, so the same graph no longer changes model, cost and tool capability with whoever pressed start; the connector ceiling and the refusal of a run started by a friend's Bot still hold ([#1582](https://github.com/ThinkFlowLab/FlyBot/issues/1582)). diff --git a/core/src/host/connector-access.js b/core/src/host/connector-access.js index 15b7791c2..eb16ce750 100644 --- a/core/src/host/connector-access.js +++ b/core/src/host/connector-access.js @@ -54,11 +54,11 @@ export function install(rt) { const tools = bot?.stepTools ?? [] return tools.includes(id) || tools.includes('*') || tools.some(name => name === id || String(name).startsWith(`mcp__${id}__`)) } - // #1544: a run Bot must not reopen a connector a Bot switched off. The record names the Bots whose - // switches cap it — the caller that started the run (createdBy), and the Bot that saved the graph - // (cappedBy), which is the ceiling when the board, the canvas or the API started the run. A Bot that - // no longer exists, and a record with neither field (an older run), leave the answer as it was: - // there is no switch list to intersect with. + // #1544: a run Bot must not reopen a connector a Bot switched off. `cappedBy` names the Bots whose + // switches cap it — the caller that started the run, and the Bot that saved the graph, which is the + // ceiling when the board, the canvas or the API started the run. `createdBy` still counts for a Bot + // made by another Bot (and for a run record written before #1582). A Bot that no longer exists, and + // a record with neither field (an older run), leave the answer as it was: there is no switch list. const capsOf = (bot) => { const ids = [bot?.createdBy, ...(Array.isArray(bot?.cappedBy) ? bot.cappedBy : [])] .filter(id => typeof id === 'string' && id !== '') @@ -71,14 +71,12 @@ export function install(rt) { } const capOff = (bot, id) => capsOf(bot).find(cap => turnedOff(cap, id)) // #1555: `run_workflow`/`define_workflow` have no `visibleTo`, so a friend's Bot can call them and a run - // Bot can name it as the one that started the run (`createdBy`). A remote Bot has no switch table of ours - // to intersect with, so it reads as "every connector switched off": the node's `tools` cannot hand the run - // Bot a connector. A record with no `createdBy` (the owner, the board, an older run) keeps the answer it - // had, and a `createdBy` that names a Bot we do not have leaves it as it was too. - const remoteCaller = (bot) => { - const id = bot?.createdBy - return (typeof id === 'string' && id !== '' ? rt.botOf(id) : undefined)?.remote !== undefined - } + // Bot can name it among the Bots whose switches cap the run (`cappedBy`; a record written before #1582 + // named it in `createdBy`). A remote Bot has no switch table of ours to intersect with, so it reads as + // "every connector switched off": the node's `tools` cannot hand the run Bot a connector. A run with no + // cap (the owner, the board, an older run) keeps the answer it had, and a cap that names a Bot we do not + // have leaves it as it was too. + const remoteCaller = (bot) => capsOf(bot).some(cap => cap.remote !== undefined) const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id) && (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined && !remoteCaller(bot))) diff --git a/core/src/host/roster.js b/core/src/host/roster.js index be9022138..626669540 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -77,7 +77,10 @@ export function install(rt) { // 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 + // A run Bot (#1582) is not a Bot's child either: `createdBy` on one names the caller whose + // connector switches cap it, not a maker, so it starts on the profile default like the board, + // the canvas and the API do. Its model must not change with whoever pressed start. + const maker = !model && !pickModel && !isRemote && !ephemeral && createdBy ? rt.botOf(createdBy) : undefined // A brief is a parent's context (`create_bot`), so it becomes instructions only when // another Bot made this one. A line the user typed in the new-Bot card is their first // request: the client sends it as a turn, and it is never saved as a standing rule (#645). diff --git a/core/test/connector-access.test.js b/core/test/connector-access.test.js index 887b2abc9..a601e97e7 100644 --- a/core/test/connector-access.test.js +++ b/core/test/connector-access.test.js @@ -588,21 +588,25 @@ test('a run Bot does not reopen a connector the Bot that started the run switche ...(cappedBy === undefined ? {} : { cappedBy }), } } - runBot('caller-off', { createdBy: 'writer' }) - runBot('caller-on', { createdBy: 'chief' }) - runBot('caller-gone', { createdBy: 'removed-bot' }) - runBot('author-off', { cappedBy: ['writer'] }) - runBot('both-on', { createdBy: 'chief', cappedBy: ['chief'] }) + // #1582: the caller now travels in `cappedBy` with the graph's author, so the ceiling reads one + // field. A run record written before it named the caller in `createdBy` still gets the same answer. + runBot('caller-off', { cappedBy: ['writer'] }) + runBot('caller-on', { cappedBy: ['chief'] }) + runBot('caller-gone', { cappedBy: ['removed-bot'] }) + runBot('author-off', { cappedBy: ['chief', 'writer'] }) + runBot('both-on', { cappedBy: ['chief'] }) + runBot('legacy-caller-off', { createdBy: 'writer' }) const { agentFor, hiddenFrom, guard, stop } = await setup({ state }) t.after(stop) - await Promise.all(['caller-off', 'caller-on', 'caller-gone', 'author-off', 'both-on'].map(id => agentFor(id))) + await Promise.all(['caller-off', 'caller-on', 'caller-gone', 'author-off', 'both-on', 'legacy-caller-off'].map(id => agentFor(id))) assert.match(guard(GITHUB[0], 'caller-off'), /turned off for Writer; the step's tools list cannot turn it back on/, 'the switch is the reason, not the step list') assert.deepEqual(sorted(hiddenFrom('caller-off')), sorted([...COMPOSIO, ...GITHUB]), 'and the tool is out of the run Bot\'s list too') + assert.match(guard(GITHUB[0], 'legacy-caller-off'), /turned off for Writer; the step's tools list cannot turn it back on/, 'a pre-#1582 record keeps its ceiling') assert.equal(guard(GITHUB[0], 'caller-on'), undefined, 'a caller that still has github keeps the #1535 answer') assert.deepEqual(sorted(hiddenFrom('caller-on')), sorted(COMPOSIO)) // A run nobody's tool call started (the board, the canvas, the API) has no caller; the Bot that - // saved the graph is the ceiling then. + // saved the graph is the ceiling then. Both ids are caps in the same list. assert.match(guard(GITHUB[0], 'author-off'), /turned off for Writer; the step's tools list cannot turn it back on/) assert.deepEqual(sorted(hiddenFrom('author-off')), sorted([...COMPOSIO, ...GITHUB])) assert.equal(guard(GITHUB[0], 'both-on'), undefined) @@ -611,29 +615,33 @@ test('a run Bot does not reopen a connector the Bot that started the run switche }) // #1555: `run_workflow` / `define_workflow` have no `visibleTo`, so a friend's Bot can call them. A run -// Bot then names that remote Bot as the one that started it, and a remote Bot has no switch table of ours -// to intersect with — so the caller reads as "every connector switched off", and the step list cannot hand -// the run Bot anything. Nothing changes for a team Bot: its own switch and the node's `tools` still decide. +// Bot then names that remote Bot among the Bots that cap it (`cappedBy`, #1582; `createdBy` before it), +// and a remote Bot has no switch table of ours to intersect with — so the cap reads as "every connector +// switched off", and the step list cannot hand the run Bot anything. Nothing changes for a team Bot: its +// own switch and the node's `tools` still decide. test('a run Bot started by a friend\'s Bot reaches no connector', async (t) => { const state = team() state.bots.friend = { id: 'friend', sessionId: 'friend', name: 'Bob', role: 'Bot', instructions: '', createdAt: 5, remote: { handle: '@bob.home' } } - const run = (id, { createdBy, stepTools = ['github'] } = {}) => { + const run = (id, { createdBy, cappedBy, stepTools = ['github'] } = {}) => { state.bots[id] = { id, sessionId: id, name: id, role: 'Run', instructions: '', createdAt: 6, hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools, ...(createdBy === undefined ? {} : { createdBy }), + ...(cappedBy === undefined ? {} : { cappedBy }), } } - run('remote-run', { createdBy: 'friend' }) - run('team-run', { createdBy: 'chief' }) + run('remote-run', { cappedBy: ['friend'] }) + run('team-run', { cappedBy: ['chief'] }) + run('legacy-remote-run', { createdBy: 'friend' }) const { agentFor, hiddenFrom, guard, stop } = await setup({ state }) t.after(stop) - await Promise.all(['remote-run', 'team-run'].map(id => agentFor(id))) + await Promise.all(['remote-run', 'team-run', 'legacy-remote-run'].map(id => agentFor(id))) // Red before this change: the node names github, so the step list alone let the run Bot through. assert.match(guard(GITHUB[0], 'remote-run'), /started by a friend's Bot/, 'the caller is the reason, not the step list') assert.deepEqual(sorted(hiddenFrom('remote-run')), sorted([...COMPOSIO, ...GITHUB]), 'and both connectors are out of its list too') assert.match(guard(COMPOSIO[0], 'remote-run'), /started by a friend's Bot/) + assert.match(guard(GITHUB[0], 'legacy-remote-run'), /started by a friend's Bot/, 'a pre-#1582 record keeps the refusal') // The no-over-refusal case: a team Bot started this run, its switch is on, the node names github. assert.equal(guard(GITHUB[0], 'team-run'), undefined, 'a team caller keeps what the node names') assert.deepEqual(sorted(hiddenFrom('team-run')), sorted(COMPOSIO), 'only the connector the node did not name stays out') diff --git a/core/test/runbot-model.test.js b/core/test/runbot-model.test.js new file mode 100644 index 000000000..947a7c2e4 --- /dev/null +++ b/core/test/runbot-model.test.js @@ -0,0 +1,79 @@ +// #1582: a run Bot must not take the model of the Bot that started the run. `createdBy` is how the +// roster says "a Bot made this Bot, so with no model named it starts on its maker's model" (#410); +// a run Bot is not a Bot's child, so the field must never reach that rule. The profile default is +// what the board, the canvas and the API already use for a node that names no model. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 1, mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, model: 'deepseek/deepseek-v4-pro' } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +function start(teamState, { models = [] } = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-runbot-model-')) + mkdirSync(home, { recursive: true }) + writeFileSync(join(home, 'state.json'), JSON.stringify(teamState)) + const ctx = fakeContext() + const sent = [] + let sessions = 0 + ctx.logger.warn = () => {} + Object.assign(ctx, { + llm: { + listProviders: () => (models.length > 0 ? [{ id: 'deepseek', name: 'DeepSeek' }] : []), + listModels: async () => models.map(id => ({ id })), + resolveModelInfo: async () => ({}), + }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: `s${++sessions}` }), + resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: m => sent.push({ sessionId, message: m }) } }), + selectModel: async () => {}, + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { + if (options.path.endsWith('bot')) route = options + return register(options) + } + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() + const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } + return { rt, ctx, stop, call } +} + +const settle = () => new Promise(resolve => setTimeout(resolve, 30)) + +test('#1582: a run Bot with a caller and no model takes the profile default, not the caller model', async (t) => { + const { rt, ctx, stop, call } = start(team(), { models: ['deepseek-v4-flash', 'deepseek-v4-pro'] }) + t.after(stop) + const teamView = (await call('state', {})).value + const chiefId = teamView.mainBotId ?? 'a' + await call('update-bot', { id: chiefId, model: 'deepseek/deepseek-v4-pro' }) + await new Promise(resolve => setTimeout(resolve, 300)) + assert.equal(await rt.desiredModel(rt.botOf('a')), 'deepseek/deepseek-v4-pro', 'the caller really is on the other model') + assert.equal(await rt.defaultModel(), 'deepseek/deepseek-v4-flash') + await settle() + + const createRunBot = async (name, extra) => ctx.flybot.bot.create({ + name, role: 'Run', instructions: 'Work.', hidden: true, + ephemeral: true, tools: ['github'], + ...extra, + }) + const withCaller = await createRunBot('Probe WithCaller', { createdBy: 'a' }) + const without = await createRunBot('Probe NoCaller', {}) + const explicit = await createRunBot('Probe Explicit', { createdBy: 'a', model: 'deepseek/deepseek-v4-flash' }) + assert.equal(rt.botOf(withCaller)?.model, 'deepseek/deepseek-v4-flash', 'a run Bot must not take the caller model') + assert.equal(rt.botOf(without)?.model, 'deepseek/deepseek-v4-flash') + assert.equal(rt.botOf(explicit)?.model, 'deepseek/deepseek-v4-flash', 'an explicit model still wins') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a70906475..aaf399208 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -331,10 +331,10 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - **Lines from elsewhere mid-turn (#455):** a `postHuman` or `postRemote` post (`source.human` / `source.remote`) that arrives while the group Session is `running` is not `followup`ed, which would park it in DSH's composer queue (raw `[Group post from …]`, steerable). `postToRoom` holds it (`heldPosts`): the round that is running (`rt.roomProgress`) shows it at once, as `personReply` or a `{ kind: 'remote' }` reply (left out of round reports), and a Bot's line is logged then (a person's line is in the log already through `applyFaceLine`); after `agent.whenIdle()` it is `followup`ed with `shown: true`, so its own round routes it by the usual rules but `group_relay` neither shows nor logs it again. With no round running it is only held and goes as before. The same hold covers this home's **own** Bot's line (#595: a late reply or a `post_to_group` that lands mid-turn showed the raw header in the composer queue): an ordinary line is logged and drawn as the ordinary `{ botId, name, color, seq }` bubble its own reply is, and a Bot's round event (`failed`, `stopped`, `resumed`) or other quiet post is held without being drawn, so its header never reaches the composer either and `group_relay` draws it as that event once the turn is done. A room notice (the `face-notice` sender) keeps the queue path: it is not a Bot's line. 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 plain line routes at the opener the same way (#462): `postHuman` posts it from the quiet `face-notice` sender with `human`, and `isPlainFaceLine` reads such a line by its `plain` flag (`friendLine`), so it gets `route`, `single` and the last speaker first; a friend's line that asks my Bots (`plain` or `callIds`, `friendAsks`) picks the speakers over a Bot post queued with it, while their `@Bot` line (`callIds`) stays unrouted. 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. **Other homes' answers count (#475):** each home runs its own round over its own Bots, so a friend's Bot line logged after the person's line and after the round opened (`friendAnswers`; a Bot post queued before the round is not counted, it may be older than the line) ends a non-broadcast routed round like a local answer and cancels `fallbackDue`; a Bot still asked (a line to everyone) is told in `routeNote` that ` already answered this line or posted a plan; add only your role's part`. `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; another home's Bot's `@all` / `@所有人` wakes none of mine, only the local Bots it also names (#475). 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. A line to a room whose `face.status` is `left` or `closed` is refused by `send` and `room-post` (`bot/room-ended`) and nothing is appended: a room a switched-away server can no longer reach must not look sent (the social plugin marks those rooms `left` when the server changes). **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). 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), 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`. +- 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). A run Bot is not a maker's child (#1582): its `createdBy`, when a caller still passes one, is the connector ceiling and not a parent, so with no model named it starts on the profile default like the board, the canvas and the API — the same graph must not run on a different model because another Bot pressed start. Covered by `core/test/roster.test.js` and `core/test/runbot-model.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. A run Bot is the exception: `createBot` stores `hidden` and `ephemeral: { plugin }` before the first save, and the wrapper does not fire `bot.created` for it. Covered by `core/test/flybot.test.js`. -- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. It is not a permissionless shell: `flybot.bot.create` may pass `tools` and `permission` for an ephemeral Bot only. `stepTools` is what connector-access and the approval listener read. `github` grants `mcp__github__*`; a connector the step did not name is hidden and a call is refused (`not granted to this run`). A run Bot is also capped by the Bots whose connector switches apply to it (#1544): `createdBy` names the Bot whose tool call started the run, `cappedBy` names every Bot whose switches cap it (the caller and the Bot that saved the workflow graph), and `connector-access.js` refuses a connector turned off for any of them with `turned off for ; the step's tools list cannot turn it back on`, dropping it from the run Bot's own list as well. A Bot that no longer exists, and a run recorded before this with neither field, leaves the answer as it was: there is no switch list to intersect with. A save with no Bot behind it (the canvas, the API, a template copy) records no author, which is the owner's own edit and lifts that ceiling again. A hidden run Bot cannot show an approval card, so a granted call is `allowed-once` and an ungranted connector or an ungranted full-access shell escalation is `rejected`. **It is not a team member either (#1445):** a node's `tools` covers connectors and the shell, not the team surface, so `EPHEMERAL_DENIED_TOOLS` in `tools.js` names every tool whose effect reaches past the run Bot's own Session — `message_bot`/`stop_bot`/`create_bot`/`update_bot`, groups and posts, `ask_user`, the capability writes, `request_secret`/`list_secrets`, the schedule writes, `send_message`, the social and workflow tools, `read_browser`, `recall` (its default scope reads the team memory) and `create_child_bot`. `connector-access.js` refuses each call with `A running Bot is not a team member.`, `approvals.js` answers `rejected` for the same names (also for a subagent child Session, by following the parent chain through `rt.whoseAgent`), and the run Bot's own list loses them through `restrict({ deny })`; a name the profile serves but the mask cannot enforce stays listed with a warn line, and the guard is what refuses it. `EPHEMERAL_ALLOWED_TOOLS` is the rest: the read-only tools (`list_bots`, `list_models`, `list_capabilities`, `bot_schedules`, `schedule_list`, `read_own_chat`, `read_group_chat`) and `remember`/`forget`, which `writeDenied` keeps inside the run Bot's own scope. Ordinary Bots, friend's Bots and group rooms are unchanged, and `plugins/workflow/**` is not edited: the node's `tools` still decides connectors and the shell. Covered by `core/test/flybot.test.js`, `core/test/connector-access.test.js` and `core/test/run-bot-tools.test.js`. +- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. It is not a permissionless shell: `flybot.bot.create` may pass `tools` and `permission` for an ephemeral Bot only. `stepTools` is what connector-access and the approval listener read. `github` grants `mcp__github__*`; a connector the step did not name is hidden and a call is refused (`not granted to this run`). A run Bot is also capped by the Bots whose connector switches apply to it (#1544): `cappedBy` names every Bot whose switches cap it — the Bot whose tool call started the run and the Bot that saved the workflow graph — and `connector-access.js` refuses a connector turned off for any of them with `turned off for ; the step's tools list cannot turn it back on`, dropping it from the run Bot's own list as well. The caller travels in `cappedBy` and not in `createdBy` (#1582), so a run Bot never reads as a Bot's child; a record written with `createdBy` before that still caps the run. A Bot that no longer exists, and a run recorded before this with neither field, leaves the answer as it was: there is no switch list to intersect with. A save with no Bot behind it (the canvas, the API, a template copy) records no author, which is the owner's own edit and lifts that ceiling again. A hidden run Bot cannot show an approval card, so a granted call is `allowed-once` and an ungranted connector or an ungranted full-access shell escalation is `rejected`. **It is not a team member either (#1445):** a node's `tools` covers connectors and the shell, not the team surface, so `EPHEMERAL_DENIED_TOOLS` in `tools.js` names every tool whose effect reaches past the run Bot's own Session — `message_bot`/`stop_bot`/`create_bot`/`update_bot`, groups and posts, `ask_user`, the capability writes, `request_secret`/`list_secrets`, the schedule writes, `send_message`, the social and workflow tools, `read_browser`, `recall` (its default scope reads the team memory) and `create_child_bot`. `connector-access.js` refuses each call with `A running Bot is not a team member.`, `approvals.js` answers `rejected` for the same names (also for a subagent child Session, by following the parent chain through `rt.whoseAgent`), and the run Bot's own list loses them through `restrict({ deny })`; a name the profile serves but the mask cannot enforce stays listed with a warn line, and the guard is what refuses it. `EPHEMERAL_ALLOWED_TOOLS` is the rest: the read-only tools (`list_bots`, `list_models`, `list_capabilities`, `bot_schedules`, `schedule_list`, `read_own_chat`, `read_group_chat`) and `remember`/`forget`, which `writeDenied` keeps inside the run Bot's own scope. Ordinary Bots, friend's Bots and group rooms are unchanged, and `plugins/workflow/**` is not edited: the node's `tools` still decides connectors and the shell. Covered by `core/test/flybot.test.js`, `core/test/connector-access.test.js` and `core/test/run-bot-tools.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) @@ -440,7 +440,7 @@ The connector tools are mounted once for the whole DSH profile, so which Bot may **Decision (the owner, 2026-10-22, #601: 「放开,主要应该用连接器工具」; it supersedes #369's "off by default for other people's use"; one exception, #1237, is the group room's composio and schedule tools, below).** Every turn of every one of the owner's own Bots may use the connected connectors, whatever started it: the owner, a group round of any origin (also a room with a friend or another person in it), a Bot-to-Bot relay, a scheduled turn, a friend talking to a shared Bot, a turn with no recorded origin. The connector tools no longer read `turn-origin.js`. - **Per-Bot record** (only what differs from the default is stored): `bot.connectors = { github?: false, composio?: false }` is an explicit off for that Bot (absent = on; a legacy `true` from #346 reads as on). `bot-connectors { botId, connector, enabled }` writes it; a `scope` an older client still sends is ignored. The retired "also when others use it" offer (`bot.connectorsShared`) means nothing any more and is dropped on the Bot's next switch or on a disconnect. -- **A run Bot started by a friend's Bot reaches no connector (#1555).** `run_workflow` and `define_workflow` have no `visibleTo`, so a friend's Bot can call them; the run Bot's `createdBy` then names that remote Bot, which has no switch table of ours to intersect with. The ephemeral branch reads that as "every connector switched off": `allowed()` requires the step tools to name the connector **and** a caller that is not remote, so the guard refuses with `The github connector is not available to a run started by a friend's Bot.` and the same `denied()` takes the connectors out of the run Bot's tool list. A run with no `createdBy` (the owner, the board, an older run) and a `createdBy` that names a Bot the team no longer has keep the answer they had, and an in-team caller whose own switch is on, whose node names the connector, still gets it. `visibleTo` on the two tools stays backlog. +- **A run Bot started by a friend's Bot reaches no connector (#1555).** `run_workflow` and `define_workflow` have no `visibleTo`, so a friend's Bot can call them; the run Bot names that remote Bot among its caps (`cappedBy`; a record written before #1582 named it in `createdBy`), which has no switch table of ours to intersect with. The ephemeral branch reads that as "every connector switched off": `allowed()` requires the step tools to name the connector **and** a caller that is not remote, so the guard refuses with `The github connector is not available to a run started by a friend's Bot.` and the same `denied()` takes the connectors out of the run Bot's tool list. A run with no cap (the owner, the board, an older run) and a cap that names a Bot the team no longer has keep the answer they had, and an in-team caller whose own switch is on, whose node names the connector, still gets it. `visibleTo` on the two tools stays backlog. - **Boundaries that stay.** A friend's Bot (`bot.remote`) never gets the tools. An Agent whose Bot cannot be determined (a parent Session that is gone, or a parent chain deeper than eight) gets none either (fail closed), so an unresolved sub-agent never reads as a plain owner Session. A chain that really ends at a Session of no Bot (the DSH Agent, a plain Session) keeps them. A sub-agent Session is followed to its parent Bot (`whoseAgent`, also on `rt.whoseAgent` for `secrets.js`). - **Enforcement is two layers.** A `ctx.tools.guard` refuses every `mcp__github__*` / `mcp__composio__*` call that is not allowed, and `agent.ctx.tools.restrict({ deny })` takes the same tools out of that Agent's tool list. The mask is refreshed when the Agent is created, on every `resolveAgent` (delivery), when a switch changes, and on `tools/change` (a connector that mounts or goes away later). Turns and sharing no longer change it. DSH's hidden "Tool added/removed" notices for these tools are not drawn in the chat (`cells.js`). - **A mask that cannot be rebuilt keeps the previous one (#1271).** `hideConnectorTools` lifts the old mask before it reads `schemas()` again, so a `restrict()` that refuses a name would otherwise leave the Agent with no mask and every tool listed again. The rebuild now names only what the global view (`schemas()` with no Agent) can restrict, and if a restriction still fails the catch puts the previous deny list back: the guard refuses a call either way, and a list that widens by itself is a wrong answer. A denied name the Agent itself serves stays listed — `restrict()` cannot name a scope's own tool — and the warn line says so. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 38248a9d9..23022de4d 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -66,4 +66,4 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, ## Connector ceiling (#1544) -A connector a Bot switched off is not reopened by a Workflow, whichever surface started the run. A run records the Bots whose switches cap it: the Bot whose `run_workflow` call started it (`createdBy`), and the Bot that saved the graph (`authoredBy` on the workflow record, set by `define_workflow` and by any save that names a Bot). Every node's run Bot carries `cappedBy` — the caller, else the graph's author — and `core/src/host/connector-access.js` intersects the node's `tools` with those switches, so a node that names a connector its caller or author turned off is refused with `turned off for ; the step's tools list cannot turn it back on`, and the tool is masked out of the run Bot's list too. A run nobody's tool call started (the board's `ingest`, the canvas, the `workflow/*` API) has no caller and is capped by the graph's author alone; a save with no Bot behind it records no author, which is the owner's own edit and leaves no ceiling; a graph saved before this has none either. `visibleTo` on `run_workflow`/`define_workflow` is unchanged: the ceiling is the fix, not limiting who may define a Workflow. +A connector a Bot switched off is not reopened by a Workflow, whichever surface started the run. A run records the Bots whose switches cap it, both in `cappedBy`: the Bot whose `run_workflow` call started it, and the Bot that saved the graph (`authoredBy` on the workflow record, set by `define_workflow` and by any save that names a Bot). A run Bot carries no `createdBy` (#1582): the roster reads that field as "a Bot made this Bot" and would start the run Bot on its maker's model, so the caller travels in `cappedBy` only — a ceiling, not a parent. Every node's run Bot carries `cappedBy` — the caller, else the graph's author — and `core/src/host/connector-access.js` intersects the node's `tools` with those switches, so a node that names a connector its caller or author turned off is refused with `turned off for ; the step's tools list cannot turn it back on`, and the tool is masked out of the run Bot's list too. A run nobody's tool call started (the board's `ingest`, the canvas, the `workflow/*` API) has no caller and is capped by the graph's author alone; a save with no Bot behind it records no author, which is the owner's own edit and leaves no ceiling; a graph saved before this has none either. `visibleTo` on `run_workflow`/`define_workflow` is unchanged: the ceiling is the fix, not limiting who may define a Workflow. diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 051d95834..74d5a4673 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -149,7 +149,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js state.workspaceId = workspace.id await rt.save() return workspace.id -@@ -56,18 +56,46 @@ export function install(rt) { +@@ -56,18 +56,49 @@ export function install(rt) { return `${stem} ${index}` } @@ -181,7 +181,10 @@ diff --git a/src/host/roster.js b/src/host/roster.js + // 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 ++ // A run Bot (#1582) is not a Bot's child either: `createdBy` on one names the caller whose ++ // connector switches cap it, not a maker, so it starts on the profile default like the board, ++ // the canvas and the API do. Its model must not change with whoever pressed start. ++ const maker = !model && !pickModel && !isRemote && !ephemeral && createdBy ? rt.botOf(createdBy) : undefined + // A brief is a parent's context (`create_bot`), so it becomes instructions only when + // another Bot made this one. A line the user typed in the new-Bot card is their first + // request: the client sends it as a turn, and it is never saved as a standing rule (#645). diff --git a/patches/ds-bot/files/src/host/connector-access.js b/patches/ds-bot/files/src/host/connector-access.js index 15b7791c2..eb16ce750 100644 --- a/patches/ds-bot/files/src/host/connector-access.js +++ b/patches/ds-bot/files/src/host/connector-access.js @@ -54,11 +54,11 @@ export function install(rt) { const tools = bot?.stepTools ?? [] return tools.includes(id) || tools.includes('*') || tools.some(name => name === id || String(name).startsWith(`mcp__${id}__`)) } - // #1544: a run Bot must not reopen a connector a Bot switched off. The record names the Bots whose - // switches cap it — the caller that started the run (createdBy), and the Bot that saved the graph - // (cappedBy), which is the ceiling when the board, the canvas or the API started the run. A Bot that - // no longer exists, and a record with neither field (an older run), leave the answer as it was: - // there is no switch list to intersect with. + // #1544: a run Bot must not reopen a connector a Bot switched off. `cappedBy` names the Bots whose + // switches cap it — the caller that started the run, and the Bot that saved the graph, which is the + // ceiling when the board, the canvas or the API started the run. `createdBy` still counts for a Bot + // made by another Bot (and for a run record written before #1582). A Bot that no longer exists, and + // a record with neither field (an older run), leave the answer as it was: there is no switch list. const capsOf = (bot) => { const ids = [bot?.createdBy, ...(Array.isArray(bot?.cappedBy) ? bot.cappedBy : [])] .filter(id => typeof id === 'string' && id !== '') @@ -71,14 +71,12 @@ export function install(rt) { } const capOff = (bot, id) => capsOf(bot).find(cap => turnedOff(cap, id)) // #1555: `run_workflow`/`define_workflow` have no `visibleTo`, so a friend's Bot can call them and a run - // Bot can name it as the one that started the run (`createdBy`). A remote Bot has no switch table of ours - // to intersect with, so it reads as "every connector switched off": the node's `tools` cannot hand the run - // Bot a connector. A record with no `createdBy` (the owner, the board, an older run) keeps the answer it - // had, and a `createdBy` that names a Bot we do not have leaves it as it was too. - const remoteCaller = (bot) => { - const id = bot?.createdBy - return (typeof id === 'string' && id !== '' ? rt.botOf(id) : undefined)?.remote !== undefined - } + // Bot can name it among the Bots whose switches cap the run (`cappedBy`; a record written before #1582 + // named it in `createdBy`). A remote Bot has no switch table of ours to intersect with, so it reads as + // "every connector switched off": the node's `tools` cannot hand the run Bot a connector. A run with no + // cap (the owner, the board, an older run) keeps the answer it had, and a cap that names a Bot we do not + // have leaves it as it was too. + const remoteCaller = (bot) => capsOf(bot).some(cap => cap.remote !== undefined) const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id) && (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined && !remoteCaller(bot))) diff --git a/patches/ds-bot/files/test/connector-access.test.js b/patches/ds-bot/files/test/connector-access.test.js index 887b2abc9..a601e97e7 100644 --- a/patches/ds-bot/files/test/connector-access.test.js +++ b/patches/ds-bot/files/test/connector-access.test.js @@ -588,21 +588,25 @@ test('a run Bot does not reopen a connector the Bot that started the run switche ...(cappedBy === undefined ? {} : { cappedBy }), } } - runBot('caller-off', { createdBy: 'writer' }) - runBot('caller-on', { createdBy: 'chief' }) - runBot('caller-gone', { createdBy: 'removed-bot' }) - runBot('author-off', { cappedBy: ['writer'] }) - runBot('both-on', { createdBy: 'chief', cappedBy: ['chief'] }) + // #1582: the caller now travels in `cappedBy` with the graph's author, so the ceiling reads one + // field. A run record written before it named the caller in `createdBy` still gets the same answer. + runBot('caller-off', { cappedBy: ['writer'] }) + runBot('caller-on', { cappedBy: ['chief'] }) + runBot('caller-gone', { cappedBy: ['removed-bot'] }) + runBot('author-off', { cappedBy: ['chief', 'writer'] }) + runBot('both-on', { cappedBy: ['chief'] }) + runBot('legacy-caller-off', { createdBy: 'writer' }) const { agentFor, hiddenFrom, guard, stop } = await setup({ state }) t.after(stop) - await Promise.all(['caller-off', 'caller-on', 'caller-gone', 'author-off', 'both-on'].map(id => agentFor(id))) + await Promise.all(['caller-off', 'caller-on', 'caller-gone', 'author-off', 'both-on', 'legacy-caller-off'].map(id => agentFor(id))) assert.match(guard(GITHUB[0], 'caller-off'), /turned off for Writer; the step's tools list cannot turn it back on/, 'the switch is the reason, not the step list') assert.deepEqual(sorted(hiddenFrom('caller-off')), sorted([...COMPOSIO, ...GITHUB]), 'and the tool is out of the run Bot\'s list too') + assert.match(guard(GITHUB[0], 'legacy-caller-off'), /turned off for Writer; the step's tools list cannot turn it back on/, 'a pre-#1582 record keeps its ceiling') assert.equal(guard(GITHUB[0], 'caller-on'), undefined, 'a caller that still has github keeps the #1535 answer') assert.deepEqual(sorted(hiddenFrom('caller-on')), sorted(COMPOSIO)) // A run nobody's tool call started (the board, the canvas, the API) has no caller; the Bot that - // saved the graph is the ceiling then. + // saved the graph is the ceiling then. Both ids are caps in the same list. assert.match(guard(GITHUB[0], 'author-off'), /turned off for Writer; the step's tools list cannot turn it back on/) assert.deepEqual(sorted(hiddenFrom('author-off')), sorted([...COMPOSIO, ...GITHUB])) assert.equal(guard(GITHUB[0], 'both-on'), undefined) @@ -611,29 +615,33 @@ test('a run Bot does not reopen a connector the Bot that started the run switche }) // #1555: `run_workflow` / `define_workflow` have no `visibleTo`, so a friend's Bot can call them. A run -// Bot then names that remote Bot as the one that started it, and a remote Bot has no switch table of ours -// to intersect with — so the caller reads as "every connector switched off", and the step list cannot hand -// the run Bot anything. Nothing changes for a team Bot: its own switch and the node's `tools` still decide. +// Bot then names that remote Bot among the Bots that cap it (`cappedBy`, #1582; `createdBy` before it), +// and a remote Bot has no switch table of ours to intersect with — so the cap reads as "every connector +// switched off", and the step list cannot hand the run Bot anything. Nothing changes for a team Bot: its +// own switch and the node's `tools` still decide. test('a run Bot started by a friend\'s Bot reaches no connector', async (t) => { const state = team() state.bots.friend = { id: 'friend', sessionId: 'friend', name: 'Bob', role: 'Bot', instructions: '', createdAt: 5, remote: { handle: '@bob.home' } } - const run = (id, { createdBy, stepTools = ['github'] } = {}) => { + const run = (id, { createdBy, cappedBy, stepTools = ['github'] } = {}) => { state.bots[id] = { id, sessionId: id, name: id, role: 'Run', instructions: '', createdAt: 6, hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools, ...(createdBy === undefined ? {} : { createdBy }), + ...(cappedBy === undefined ? {} : { cappedBy }), } } - run('remote-run', { createdBy: 'friend' }) - run('team-run', { createdBy: 'chief' }) + run('remote-run', { cappedBy: ['friend'] }) + run('team-run', { cappedBy: ['chief'] }) + run('legacy-remote-run', { createdBy: 'friend' }) const { agentFor, hiddenFrom, guard, stop } = await setup({ state }) t.after(stop) - await Promise.all(['remote-run', 'team-run'].map(id => agentFor(id))) + await Promise.all(['remote-run', 'team-run', 'legacy-remote-run'].map(id => agentFor(id))) // Red before this change: the node names github, so the step list alone let the run Bot through. assert.match(guard(GITHUB[0], 'remote-run'), /started by a friend's Bot/, 'the caller is the reason, not the step list') assert.deepEqual(sorted(hiddenFrom('remote-run')), sorted([...COMPOSIO, ...GITHUB]), 'and both connectors are out of its list too') assert.match(guard(COMPOSIO[0], 'remote-run'), /started by a friend's Bot/) + assert.match(guard(GITHUB[0], 'legacy-remote-run'), /started by a friend's Bot/, 'a pre-#1582 record keeps the refusal') // The no-over-refusal case: a team Bot started this run, its switch is on, the node names github. assert.equal(guard(GITHUB[0], 'team-run'), undefined, 'a team caller keeps what the node names') assert.deepEqual(sorted(hiddenFrom('team-run')), sorted(COMPOSIO), 'only the connector the node did not name stays out') diff --git a/patches/ds-bot/files/test/runbot-model.test.js b/patches/ds-bot/files/test/runbot-model.test.js new file mode 100644 index 000000000..947a7c2e4 --- /dev/null +++ b/patches/ds-bot/files/test/runbot-model.test.js @@ -0,0 +1,79 @@ +// #1582: a run Bot must not take the model of the Bot that started the run. `createdBy` is how the +// roster says "a Bot made this Bot, so with no model named it starts on its maker's model" (#410); +// a run Bot is not a Bot's child, so the field must never reach that rule. The profile default is +// what the board, the canvas and the API already use for a node that names no model. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 1, mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, model: 'deepseek/deepseek-v4-pro' } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +function start(teamState, { models = [] } = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-runbot-model-')) + mkdirSync(home, { recursive: true }) + writeFileSync(join(home, 'state.json'), JSON.stringify(teamState)) + const ctx = fakeContext() + const sent = [] + let sessions = 0 + ctx.logger.warn = () => {} + Object.assign(ctx, { + llm: { + listProviders: () => (models.length > 0 ? [{ id: 'deepseek', name: 'DeepSeek' }] : []), + listModels: async () => models.map(id => ({ id })), + resolveModelInfo: async () => ({}), + }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: `s${++sessions}` }), + resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: m => sent.push({ sessionId, message: m }) } }), + selectModel: async () => {}, + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { + if (options.path.endsWith('bot')) route = options + return register(options) + } + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() + const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } + return { rt, ctx, stop, call } +} + +const settle = () => new Promise(resolve => setTimeout(resolve, 30)) + +test('#1582: a run Bot with a caller and no model takes the profile default, not the caller model', async (t) => { + const { rt, ctx, stop, call } = start(team(), { models: ['deepseek-v4-flash', 'deepseek-v4-pro'] }) + t.after(stop) + const teamView = (await call('state', {})).value + const chiefId = teamView.mainBotId ?? 'a' + await call('update-bot', { id: chiefId, model: 'deepseek/deepseek-v4-pro' }) + await new Promise(resolve => setTimeout(resolve, 300)) + assert.equal(await rt.desiredModel(rt.botOf('a')), 'deepseek/deepseek-v4-pro', 'the caller really is on the other model') + assert.equal(await rt.defaultModel(), 'deepseek/deepseek-v4-flash') + await settle() + + const createRunBot = async (name, extra) => ctx.flybot.bot.create({ + name, role: 'Run', instructions: 'Work.', hidden: true, + ephemeral: true, tools: ['github'], + ...extra, + }) + const withCaller = await createRunBot('Probe WithCaller', { createdBy: 'a' }) + const without = await createRunBot('Probe NoCaller', {}) + const explicit = await createRunBot('Probe Explicit', { createdBy: 'a', model: 'deepseek/deepseek-v4-flash' }) + assert.equal(rt.botOf(withCaller)?.model, 'deepseek/deepseek-v4-flash', 'a run Bot must not take the caller model') + assert.equal(rt.botOf(without)?.model, 'deepseek/deepseek-v4-flash') + assert.equal(rt.botOf(explicit)?.model, 'deepseek/deepseek-v4-flash', 'an explicit model still wins') +}) diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 91c7a2e1f..b8e9c5b1b 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -52,9 +52,8 @@ export function apply(ctx, config = {}) { ephemeral: true, permission: permissionFor(), tools: spec.tools, - // #1544: who started the run and whose switches cap its connectors; the host reads both - // and refuses a connector a Bot switched off however the node names it. - ...(spec.createdBy ? { createdBy: spec.createdBy } : {}), + // #1544/#1582: whose switches cap the run's connectors. The caller is in `cappedBy` with + // the graph's author; the run Bot carries no `createdBy`, which the roster reads as a maker. ...(spec.cappedBy ? { cappedBy: spec.cappedBy } : {}), fields: { nodeId: spec.nodeId, tools: spec.tools ?? null }, }) diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index 92803e6d1..c6a6b5737 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -35,7 +35,9 @@ export function nodeConfig(node, roster = [], workflowModel = '') { // #1544: a connector a Bot switched off is not reopened by a Workflow. The run carries the Bots whose // switches cap it — the Bot that started the run (a tool call), and the Bot that saved the graph, which // is the ceiling when the board, the canvas or the API started it. The host intersects the node's -// `tools` with them; the graph cannot raise its own ceiling. +// `tools` with them; the graph cannot raise its own ceiling. Both ids travel in `cappedBy` only: a run +// Bot must not carry `createdBy`, because the roster reads that field as "a Bot made this Bot" and +// starts such a Bot on its maker's model (#1582). The caller is a ceiling, not a parent. export function runCaps(callerBotId, authorBotId) { const ids = [callerBotId, authorBotId].filter(id => typeof id === 'string' && id !== '') return [...new Set(ids)] @@ -44,12 +46,10 @@ export function runCaps(callerBotId, authorBotId) { export async function spawnAll(nodes, roster, spawn, workflowModel = '', caps = {}) { const made = [] const capping = runCaps(caps.callerBotId, caps.authorBotId) - const caller = typeof caps.callerBotId === 'string' && caps.callerBotId !== '' ? caps.callerBotId : undefined try { const ephemeral = {} for (const node of nodes) { const spec = nodeConfig(node, roster, workflowModel) - if (caller !== undefined) spec.createdBy = caller if (capping.length > 0) spec.cappedBy = capping const id = await spawn(spec) if (typeof id !== 'string' || id === '') throw new Error('The run could not start a Bot') diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index af33f49d9..ae6a5663c 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -797,11 +797,12 @@ test('the built-in delivery nodes hand their connector list to the host', async for (const spec of created) assert.equal(spec.permission, 'danger-full-access', 'file and shell rights are a normal Bot\'s') }) -// #1544: a connector a Bot switched off must not be reopened by a Workflow. The run records the Bot -// that started it (createdBy) and every Bot whose switches cap its connectors (cappedBy): the caller -// when a Bot's tool call started the run, and the Bot that saved the graph when the board, the canvas -// or the API did. `core/src/host/connector-access.js` intersects the node's `tools` with them. -test('#1544: the run Bot records the Bot that started the run, and the Bot that saved the graph caps it', async () => { +// #1544: a connector a Bot switched off must not be reopened by a Workflow. The run carries every Bot +// whose switches cap its connectors (cappedBy): the caller when a Bot's tool call started the run, and +// the Bot that saved the graph when the board, the canvas or the API did. Both travel in `cappedBy` and +// the run Bot carries no `createdBy` (#1582: the roster reads that field as a maker, and a run must not +// take the starter's model). `core/src/host/connector-access.js` intersects the node's `tools` with them. +test('#1544: the run Bot is capped by the Bot that started the run and the Bot that saved the graph', async () => { const { svc, spawned } = harness() svc.define({ name: 'Launder', @@ -812,7 +813,7 @@ test('#1544: the run Bot records the Bot that started the run, and the Bot that const started = await svc.runNamed({ workflow: 'Launder', brief: 'Ship the note', callerBotId: 'main' }) assert.equal(started.ok, true) const spec = spawned.at(-1).spec - assert.equal(spec.createdBy, 'main') + assert.equal('createdBy' in spec, false, 'the caller is a ceiling, not a maker (#1582)') assert.deepEqual(spec.cappedBy, ['main', 'bot-planner']) assert.deepEqual(spec.tools, ['github']) }) @@ -866,7 +867,7 @@ test('#1544: a parked run keeps the caller and the author across the queue', asy const before = spawned.length await svc.reset({ workflowId: made.id, runId: first.workflows.find(item => item.id === made.id).run.id }) const drained = spawned.slice(before).map(entry => entry.spec) - assert.deepEqual(drained.map(spec => spec.createdBy), ['main']) + assert.deepEqual(drained.map(spec => 'createdBy' in spec), [false]) assert.deepEqual(drained.map(spec => spec.cappedBy), [['main', 'bot-planner']]) }) @@ -901,7 +902,7 @@ test('#1544: the workflow tools hand the calling Bot on to the run they start', }, { botId: 'main' }) await one.specs.run_workflow.execute({ workflow: 'Caller lane', brief: 'Ship the note' }, { botId: 'bot-planner' }) const spawnedSpec = one.created.at(-1) - assert.equal(spawnedSpec.createdBy, 'bot-planner') + assert.equal('createdBy' in spawnedSpec, false, 'the caller is carried by cappedBy (#1582)') assert.deepEqual(spawnedSpec.cappedBy, ['bot-planner', 'main']) })