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/tree-root-follows-main.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 16 additions & 2 deletions plugins/tree/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)),
Expand Down
2 changes: 1 addition & 1 deletion plugins/tree/src/organize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion plugins/tree/src/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
50 changes: 50 additions & 0 deletions plugins/tree/test/tree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading