diff --git a/changelog.d/tree-root-follows-main.fixed.md b/changelog.d/tree-root-follows-main.fixed.md new file mode 100644 index 000000000..5da2ff15e --- /dev/null +++ b/changelog.d/tree-root-follows-main.fixed.md @@ -0,0 +1 @@ +Handing the Main role to another Bot now moves the tree's root with it on the next turn: exactly one root remains, the previous Chief is demoted and placed in the tree, and the new Main gets the tree tools instead of waiting for a restart ([#748](https://github.com/ThinkFlowLab/FlyBot/issues/748)). A tree that already sits at `maxDepth` stays inside it on the swap: the demoted root's branch is re-attached under the new root instead of being placed one level too deep. diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 7af5da25e..88f22b151 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -46,6 +46,10 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). - `maxChildren` and `maxDepth` apply to **every** create path while the tree plugin is loaded (#446). A Bot made without tree fields (core `create_bot`, the Bot list) is placed by role by `src/organize.js` on `bot.created`: inside the create call when it fits a level or an existing lead, right after when a new lead has to be created first (it is marked `organizing` until then). No parent goes over `maxChildren` at any moment. `create_child_bot` keeps its limit contract (`limit reached` at a full target parent). - A newly created child starts with no person-grant (`bot.hasAnyGrant` false, even when the parent is granted). Sharing with another person still needs an independent per-Bot grant (default deny). Tree never copies grants and never publishes. +## The root follows the Main Bot + +`adoptChief` marks the current Main as the root and records it as the tree's adopted root; when the Main changes it clears that record, `treeRole` and `parent` on the previously adopted root (setting it back to an unattached member, which adoption then places by role), so the adopted root follows the Main and a second root another flow created is left alone. Adoption measures a Bot by the height of its subtree, not as a leaf (#748): a demoted root whose branch does not fit under the new root is left out of that pass instead of being pushed one level past `maxDepth`, the repair after it re-attaches the branch's children to the new root, and a second pass then places the demoted root itself. A Main change on a tree that already sits at `maxDepth` therefore keeps every Bot both inside the limit and inside the tree. A turn boundary checks this before the prompts are written, because `roster.setMain` fires no event the plugin sees. `autoRoot: false` turns the whole thing off: nothing is adopted or created, neither at the start nor at a turn boundary. + ### Reorganising the tree: `move_bot` and adoption after a deletion (#635) - **`move_bot(bot, parent)`** (root only) moves an existing Bot with its subtree under another parent, found by id or by unique name, and refreshes the prompt of every Bot whose trace changed. It refuses a cycle (`cannot move X under its own descendant Y`), a no-op (`already a child of`), a duplicate sibling name, `maxChildren` at the target, and a subtree that would end deeper than `maxDepth` — the same invariants `create_child_bot` keeps, applied to a move. It never creates a Bot: deletion already re-attaches a removed Bot's children to its parent, and this is the sideways counterpart. diff --git a/plugins/tree/index.js b/plugins/tree/index.js index aa6efab3a..f0c1fc547 100644 --- a/plugins/tree/index.js +++ b/plugins/tree/index.js @@ -29,6 +29,20 @@ export function apply(ctx, config = {}) { if (typeof id === 'string' && tree.isMember(id)) tree.refreshPrompt(id) refreshParentPrompt(id) } + // A Main Bot change (roster.setMain) fires no event this plugin sees: when the current Main is not + // the root, adopt it before the turn's prompts are written, so a coordinator never works under an + // old root and the tree never has two. config.autoRoot: false turns the whole adoption off, at the + // start and here, so the flag does not merely delay it to the first turn. + const reconcileRoot = () => { + if (config.autoRoot === false) return + try { + const chief = tree.findChief() + if (chief === undefined || tree.snapshot()[chief.id]?.root === true) return + void tree.ensureRoot().catch(error => warn('flybot-tree: root not updated after a Main Bot change:', error?.message ?? error)) + } catch (error) { + warn('flybot-tree: root not updated after a Main Bot change:', error?.message ?? error) + } + } const disposers = [ fb.useSend('tree', treeSendMiddleware(tree, { bypassSocial: config.bypassSocial === true, isRemote: fb.isRemote, hasAnyGrant: fb.hasAnyGrant })), ...registerTreeTools(fb, tree), @@ -42,8 +56,8 @@ export function apply(ctx, config = {}) { // reads stays current (writeBlock only rewrites the block when the line really changed). // The feed names the turn it is about (turnId); dropping it made an end close whichever turn was // oldest, so with two open and the later one ending first, openFor reported the newer age (#696). - fb.onFeed('turn.start', e => { tree.noteTurnStart(e?.botId, e?.turnId); refreshPrompts(e?.botId) }), - fb.onFeed('turn.end', e => { tree.noteTurnEnd(e?.botId, e?.turnId); refreshPrompts(e?.botId) }), + fb.onFeed('turn.start', e => { tree.noteTurnStart(e?.botId, e?.turnId); reconcileRoot(); refreshPrompts(e?.botId) }), + fb.onFeed('turn.end', e => { tree.noteTurnEnd(e?.botId, e?.turnId); reconcileRoot(); refreshPrompts(e?.botId) }), // The tree view: what the last reorganization did, one-click undo, dismiss. fb.handleApi('tree/reorg', () => tree.overview(), { read: true }), fb.handleApi('tree/undo', payload => tree.undo(typeof payload?.runId === 'string' ? payload.runId : undefined)), diff --git a/plugins/tree/src/organize.js b/plugins/tree/src/organize.js index 839a6908f..d638964b7 100644 --- a/plugins/tree/src/organize.js +++ b/plugins/tree/src/organize.js @@ -328,7 +328,7 @@ export function createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, war const attempt = allowAsync => { const nodes = snapshot() if (!nodes[root]) return undefined - const x = { id: bot.id, name: bot.name, height: 1, p: profileOf({ id: bot.id, name: bot.name, role: bot.role, treeRole: role, prompt: ownText(fb.getBot(bot.id)?.systemPrompt), tools: get(bot.id, 'activity')?.tools ?? [] }, classify) } + const x = { id: bot.id, name: bot.name, height: heightOf(nodes, bot.id), p: profileOf({ id: bot.id, name: bot.name, role: bot.role, treeRole: role, prompt: ownText(fb.getBot(bot.id)?.systemPrompt), tools: get(bot.id, 'activity')?.tools ?? [] }, classify) } for (const at of homesFor(nodes, root, x)) { if (fits(nodes, at, x)) { placeAt(bot.id, at, role) diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index b61b38cc8..be390b137 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -119,13 +119,29 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis function adoptChief() { const chief = findChief() if (!chief) return undefined + // The Main role can move (roster.setMain): the tree marks the root it adopted, so exactly that + // one is demoted when the Main changes (a second root another flow created is left alone) and + // adoption places it like any other Bot instead of leaving two roots. + for (const bot of fb.listBots()) { + if (bot.id === chief.id || fb.getField(bot.id, 'adoptedRoot') !== true) continue + fb.setField(bot.id, 'adoptedRoot', undefined) + fb.setField(bot.id, 'treeRole', undefined) + fb.setField(bot.id, 'parent', undefined) + } + if (fb.getField(chief.id, 'adoptedRoot') !== true) fb.setField(chief.id, 'adoptedRoot', true) if (fb.getField(chief.id, 'parent') !== undefined) fb.setField(chief.id, 'parent', null) if (fb.getField(chief.id, 'treeRole') !== ROOT_ROLE) fb.setField(chief.id, 'treeRole', ROOT_ROLE) migrateLegacyRoots(chief.id) if (adoptExisting) adoptUnattached(chief.id) + // #748: a demoted root brings its subtree along, and place() no longer squeezes a branch past + // maxDepth to fit it in: that first pass leaves the branch out. The repair below re-attaches its + // children to the new root, and the second pass then places the demoted root as the leaf it is. + // Both passes are needed: dropping either one leaves the old root outside the tree for good + // (reconcileRoot stops calling us once the new Main is the root). + organizer.repair() + if (adoptExisting) adoptUnattached(chief.id) // #452: restore 归档-* / Archive-* mis-renames; hide archived from the default sidebar. try { const r = organizer.repairArchiveNames(); if (r && typeof r.then === 'function') r.catch(error => warn('flybot-tree: archive-name repair failed:', error?.message ?? error)) } catch (error) { warn('flybot-tree: archive-name repair failed:', error?.message ?? error) } - organizer.repair() refreshAll() return chief.id } diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 9a801645f..a8b9c3249 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -96,6 +96,56 @@ test('a rename made in the core reaches the tree prompts on the next turn', asyn assert.match(sp(m, work), /You are Trips, a bot in a recursive bot tree/) }) +test('a new Main Bot takes the root on the next turn, and the old root is demoted', async () => { + const m = await setup() + await make(m, m.root, 'Work') + const chief2 = m.addBot({ name: 'Chief2', isMain: true }) + m.bots.get(m.root).isMain = false + m.feed('turn.start', { botId: chief2, place: 'chat', sessionId: 's', turnId: 't', turn: 1, time: 1 }) + const roots = [...m.bots.values()].filter(bot => bot.fields['flybot-tree']?.treeRole === 'root') + assert.deepEqual(roots.map(bot => bot.name), ['Chief2'], 'exactly one root, the current Main') + assert.notEqual(f(m, m.root, 'treeRole'), 'root') + assert.equal(m.tree.isRoot(m.root), false, 'the previous root is placed like any other Bot') +}) + +test('#748: a Main change on a tree that already sits at maxDepth keeps every Bot inside the limits', async () => { + const m = createMockFlybot() + m.addBot({ name: 'Chief', isMain: true }) + const { tree } = plugin.apply({ flybot: m.flybot }, {}) + const root = await tree.ensureRoot() + const l1 = await make(m, root, 'L1') + const l2 = await make(m, l1, 'L2') + const l3 = await make(m, l2, 'L3') + const l4 = await make(m, l3, 'L4') + assert.equal(f(m, l4, 'parent'), l3, 'the chain reaches maxDepth') + const chief2 = m.addBot({ name: 'Chief2', isMain: true }) + m.bots.get(root).isMain = false + m.feed('turn.start', { botId: chief2, place: 'chat', sessionId: 's', turnId: 't', turn: 1, time: 1 }) + await tree.settled() + const nodes = tree.snapshot() + assert.equal(nodes[chief2]?.root, true, 'the new Main is the only root') + for (const bot of m.bots.values()) assert.ok(nodes[bot.id], `${bot.name} is still in the tree`) + for (const id of Object.keys(nodes)) { + assert.ok(tree.depthOf(nodes, id) <= 5, `${nodes[id].name} is at depth ${tree.depthOf(nodes, id)} (maxDepth=5)`) + if (id !== chief2) assert.ok(nodes[nodes[id].parent], `${nodes[id].name} has a parent inside the tree`) + } + // The tree and its own limits agree about the deepest Bot: one more child would be depth 6, not 7. + assert.match((await m.call(l4, 'create_child_bot', { name: 'L6', role_description: 'x' })).error, /a child of L4 would be at depth 6 \(maxDepth=5\)/) +}) + +test('autoRoot: false is not overridden by a Main Bot change at a turn boundary', async () => { + const m = createMockFlybot() + const chief = m.addBot({ name: 'Chief', isMain: true }) + const { tree } = plugin.apply({ flybot: m.flybot }, { autoRoot: false, adoptExisting: false }) + assert.deepEqual(tree.snapshot(), {}, 'no root at the start') + m.feed('turn.start', { botId: chief, place: 'chat', sessionId: 's', turnId: 't', turn: 1, time: 1 }) + m.feed('turn.end', { botId: chief, place: 'chat', sessionId: 's', turnId: 't', turn: 1, time: 2 }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(tree.snapshot(), {}, 'a turn boundary must not create the root the config turned off') + assert.equal(f(m, chief, 'treeRole'), undefined) + assert.equal(f(m, chief, 'adoptedRoot'), undefined) +}) + test('tools: configurable limits and hidden from non-tree bots', async () => { const m = await setup({ maxDepth: 2, maxChildren: 1 }) const a = await make(m, m.root, 'A')