From fe7761b1834b67694c4c0560b78fd192b1c945ac Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:07:58 +0800 Subject: [PATCH 1/2] Place every Bot in the tree under its limits; repair full levels Core create_bot made Bots with no tree fields. Once Chief had maxChildren children, adoptUnattached skipped them with a server-log warning, so they stayed outside the tree while the sidebar drew them under Chief, and a freed slot later pulled one in by list order (#446). The tree now places such a Bot synchronously on bot.created, by the same maxChildren/maxDepth limits as create_child_bot: under the root while it has room, else under the best-matching tree Bot (shared name words or the same short role label), else after grouping matching siblings under their oldest member, else one level down. An over-full parent (after a deletion) is repaired the same way. Bots are only reparented, never deleted, and each move is logged. The root prompt says create_bot does not get around the limits. ctx.logger given as an object no longer breaks the warn helper. Fixes #446 --- README.md | 6 +- README.zh.md | 6 +- changelog.d/446-tree-auto-placement.fixed.md | 1 + docs/subsystems/tree.md | 14 +- plugins/tree/README.md | 6 +- plugins/tree/index.js | 13 +- plugins/tree/src/placement.js | 157 +++++++++++++++++++ plugins/tree/src/prompt.js | 8 +- plugins/tree/src/tree.js | 52 +++--- plugins/tree/test/host.test.js | 21 +++ plugins/tree/test/tree.test.js | 99 +++++++++++- 11 files changed, 327 insertions(+), 56 deletions(-) create mode 100644 changelog.d/446-tree-auto-placement.fixed.md create mode 100644 plugins/tree/src/placement.js diff --git a/README.md b/README.md index 59de2b075..73683346c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Turns your Bots into one recursive tree that splits a request into parts, sends *Demo: Chief starts alone in the sidebar, creates the child Bots Travel, Food and Budget, hands each one a part, and then combines their answers into one plan. Some waiting is sped up.* [Watch the full video (MP4)](docs/images/demo-tree.mp4) -- **One root.** The tree's root is the Main Bot (Chief); no new bot is created for it. A separate `Root` bot is created only if the team has Bots but no Main Bot. Existing local Bots join under the root as its children, up to `maxChildren`. +- **One root.** The tree's root is the Main Bot (Chief); no new bot is created for it. A separate `Root` bot is created only if the team has Bots but no Main Bot. Every other local Bot joins the tree, including one made with `create_bot` or the Bot list, under the same limits as `create_child_bot`. When a level is full, the tree reorganizes itself instead of leaving a Bot outside: the new Bot goes under the best-matching Bot (shared name words), matching siblings are grouped under their oldest member, or it moves one level down. Bots are only moved, never deleted, and each move is logged. - **Children on demand.** Any tree Bot (not only the Main/root) calls `create_child_bot(name, role_description)` to make a child directly under *itself*; the tool takes no parent argument. Calls are handled one at a time per parent, so parallel calls never go over the limits or create two siblings with the same name. The rules: - names are 1–40 characters, and duplicate sibling names are rejected (case and spaces ignored); - depth is at most 5 (the root counts as 1), with at most 8 children per Bot (set in `limits.json` or the plugin config); @@ -49,10 +49,10 @@ Turns your Bots into one recursive tree that splits a request into parts, sends - the root may also message any local Bot; - local Bots outside the tree may message only the root; - `stopped` and `report` notices are never blocked, so Chief can always stop any Bot. -- **Reparenting.** When a Bot is deleted, its children move up to its parent. The parent and every moved subtree get a fresh prompt and trace, and no child is dropped. +- **Reparenting.** When a Bot is deleted, its children move up to its parent. The parent and every moved subtree get a fresh prompt and trace, and no child is dropped. If the parent is then over `maxChildren`, its newest children move one level down the same way. - **Trace line.** When a tree Bot answers its *parent*, it starts the answer with its path, e.g. `Chief → Work → Dev`. Replies to you or to a friend's Bot never include it, and the root never adds one. - **Your prompts are kept.** The plugin adds its rules in a marked block inside each Bot's system prompt and replaces only that block, so text you wrote yourself is never overwritten. -- **Sidebar.** The Bot list is drawn as one expandable tree with Chief on top. Bots outside the tree are shown directly under Chief. +- **Sidebar.** The Bot list is drawn as one expandable tree with Chief on top. Retired Bots and other Main Bots are shown directly under Chief; every other Bot is drawn where the tree has it. Install (needs the core, `path:/core`): diff --git a/README.zh.md b/README.zh.md index 1457cbf35..658df6a30 100644 --- a/README.zh.md +++ b/README.zh.md @@ -31,7 +31,7 @@ FlyBot 是在固定版本的 [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) *演示:侧栏里一开始只有 Chief;它建出子 Bot Travel、Food、Budget,各分一块,再把答案汇总成一份行程。等待的部分做了加速。* [观看完整视频(MP4)](docs/images/demo-tree.mp4) -- **一个根。** 树的根是主 Bot(Chief),不会再新建一个根。只有团队里有 Bot、却没有主 Bot 时,才会单独建一个 `Root`。已有的本地 Bot 会挂到根下面作子节点,数量不超过 `maxChildren`。 +- **一个根。** 树的根是主 Bot(Chief),不会再新建一个根。只有团队里有 Bot、却没有主 Bot 时,才会单独建一个 `Root`。其他本地 Bot(包括用 `create_bot` 或 Bot 列表建的)都会进树,遵守和 `create_child_bot` 一样的限制。某一层满了时,树会自动整理,而不是把 Bot 留在树外:新 Bot 挂到名字最匹配的 Bot 下(名称有相同的词),或把互相匹配的同级 Bot 归到其中最早的那个下面,或下移一层。Bot 只会被移动,从不删除,每次移动都记日志。 - **按需生子。** 树中的任意 Bot(不只是主 Bot / 根)调用 `create_child_bot(name, role_description)`,在**自己**下面直接建子 Bot;工具没有 parent 参数。同一父节点的调用串行处理,避免超限或重名。规则: - 名称 1–40 个字符;同级重名会被拒绝(忽略大小写和空格); - 深度最多 5(根算第 1 层),每个 Bot 最多 8 个子节点(见 `limits.json` 或插件配置); @@ -44,10 +44,10 @@ FlyBot 是在固定版本的 [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) - 根还可以给任意本地 Bot 发消息; - 树外的本地 Bot 只能给根发消息; - `stopped` 与 `report` 通知永不拦截,Chief 总能叫停任意 Bot。 -- **换父。** 删除某个 Bot 时,其子节点上移到它的父节点;父节点与整棵子树会刷新提示词与路径,子节点不会丢失。 +- **换父。** 删除某个 Bot 时,其子节点上移到它的父节点;父节点与整棵子树会刷新提示词与路径,子节点不会丢失。如果父节点因此超过 `maxChildren`,最新的子节点按同样的规则下移一层。 - **路径行。** 树中 Bot **向父节点**汇报时,答案以路径开头,例如 `Chief → Work → Dev`。回给你或好友 Bot 时不加路径;根也不会加。 - **保留你的提示词。** 插件把规则写在系统提示词里的标记块中,只替换该块,不会覆盖你自己写的内容。 -- **侧栏。** Bot 列表画成可展开的树,Chief 在顶上;树外的 Bot 直接挂在 Chief 下。 +- **侧栏。** Bot 列表画成可展开的树,Chief 在顶上;已退役的 Bot 和其他主 Bot 直接挂在 Chief 下;其余 Bot 都画在树里它所在的位置。 安装(需要先装核心 `path:/core`): diff --git a/changelog.d/446-tree-auto-placement.fixed.md b/changelog.d/446-tree-auto-placement.fixed.md new file mode 100644 index 000000000..8affb25f4 --- /dev/null +++ b/changelog.d/446-tree-auto-placement.fixed.md @@ -0,0 +1 @@ +Bots made outside the tree (Chief's `create_bot`, the Bot list) now always join the tree under the same `maxChildren` and `maxDepth` limits as `create_child_bot`. When a level is full, the tree reorganizes itself instead of leaving the Bot outside while the sidebar showed it under Chief: the Bot goes under the best-matching Bot, matching siblings are grouped under a lead, or it moves one level down. A parent left over the limit after a deletion is repaired the same way. Bots are only moved, never deleted, and every move is logged ([#446](https://github.com/ThinkFlowLab/FlyBot/issues/446)). diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index c652e0447..ac2fe6f99 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -23,6 +23,7 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). | File | Role | | --- | --- | | `plugins/tree/index.js` | Plugin entry. | +| `plugins/tree/src/placement.js` | Automatic placement and repair under `maxChildren` / `maxDepth` (#446). | ## Entry points @@ -38,8 +39,19 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). - Every tree member (not only the Main/root) may call `create_child_bot`. The parent is always the calling Bot; the tool has no parent argument. - `maxChildren` and depth limits stay. Parallel creates are serialized per parent. +- `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 `src/placement.js` synchronously on `bot.created`, so it is in the tree before `create_bot` answers. `create_child_bot` keeps its contract (directly under the caller, `limit reached` when full). - 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. +### Automatic placement and repair (#446) + +The tree reorganizes itself instead of leaving Bots outside it or a parent over the limit. This is the safety net; smarter allocation up front is [#447](https://github.com/ThinkFlowLab/FlyBot/issues/447). + +- **Placement** of a Bot outside the tree (creation order, deterministic): (1) under the root while it has room; (2) else under the best-matching tree Bot that has room (shared name words, case-insensitive, generic words like "bot" or "team" ignored, or the same short role label of at most 24 characters; ties go to the shallower, then older Bot), which gives a lead with children and puts a duplicate name under its namesake; (3) else the root is consolidated: its matching children move under their oldest member until there is room; (4) else one level down, under the child with the fewest descendants (oldest first), recursively. +- **Repair**: any parent over `maxChildren` (e.g. after a deletion re-attached a deleted Bot's children) is consolidated, then its newest children move down by (2)/(4) until it is within the limit. It runs on every root attempt and after every deletion. +- **Never destructive**: Bots are only reparented. No Bot is deleted, merged into another or retired; sessions, prompts and grants are untouched. Every automatic move is logged (`flybot-tree: placed … under …` / `moved … under …`). A subtree is moved only where its whole height fits `maxDepth`, and never next to a sibling with the same name. +- **No silent pull-up**: freeing a slot moves nobody up; the next new Bot takes it. +- The sidebar draws the same tree: only retired Bots and other Main Bots have no parent there. + ### Optional Tree section toggle - The Bot list itself uses the core `.bt-tree-toggle` (see [core-client](core-client.md)). When `sidebarSection: true` brings this plugin's separate Tree section back, `.flybot-tree-toggle` matches that control: 28×28 hit target, 16px inner `.flybot-tree-caret`, hover, focus ring, and rotation of the glyph only. Leaf spacers (`.flybot-tree-dot`) keep the same 28px width. `aria-expanded` and Expand/Collapse `{name}` labels stay on the button. @@ -51,7 +63,7 @@ The plugin's discarded `.catch(() => {})` calls carry a one-line reason comment ## Tests -Covered by the plugin's own tests under `plugins/tree/` / core test suite as wired. `#16 Option B` in `test/tree.test.js` (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that `maxChildren`/depth still refuse, and that the child is not auto-granted. +Covered by the plugin's own tests under `plugins/tree/` / core test suite as wired. `#16 Option B` in `test/tree.test.js` (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that `maxChildren`/depth still refuse, and that the child is not auto-granted. The `#446` tests (mock and real host, including core `create_bot`) cover placement, consolidation, duplicate names, repair after deletion, no pull-up on a freed slot, and limits across many creates. ## Common mistakes diff --git a/plugins/tree/README.md b/plugins/tree/README.md index 42ad4b150..0dc88ece1 100644 --- a/plugins/tree/README.md +++ b/plugins/tree/README.md @@ -7,15 +7,15 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness). ### What it does - **Chief is the root.** ds-bot's Main Bot (`BotView.isMain`, i.e. `state.mainBotIds`; the one named `Chief` if there are several) is adopted as the root with `treeRole: 'root'` (no `parent`); no bot is created. Every other tree bot has `parent` (its parent's id) and `treeRole` (its role). Adoption runs at startup, a few retries later and on every `bot.created`, so a Chief created after startup is picked up. Only if the team has bots but no Main Bot is a separate `Root` created. `config.rootBotId` picks the root explicitly. - **Migration:** a separate `Root` auto-created by earlier versions (parent `null`, not a Main Bot) hands its children to Chief, loses its tree fields and tree prompt, and gets `fields['flybot-tree'].retired = true` so adoption skips it. The bot itself is **never** deleted (bot-im / the user decide). Idempotent. -- **Adoption (`adoptExisting`, default true):** every local bot with no tree fields that is not retired and not another Main Bot (e.g. existing 研究员, 数据分析师, 文案, 程序员) becomes a child of Chief at startup and on `bot.created` when the creator set no `parent`. Chief's `maxChildren` is respected: extra bots stay unattached and a warning is logged. Adopted bots keep their own prompt; the tree block is added. Message rules apply to them like any tree child (Chief ↔ them; they → only Chief and their own children). +- **Adoption (`adoptExisting`, default true):** every local bot with no tree fields that is not retired and not another Main Bot (e.g. existing 研究员, 数据分析师, 文案, 程序员) is placed in the tree at startup and on `bot.created` when the creator set no `parent` (core `create_bot`, the Bot list), at once, inside the create call. **Placement (#446, `src/placement.js`)** obeys `maxChildren` and `maxDepth` like `create_child_bot`, and a full level never leaves a bot outside: (1) under Chief while Chief has room; (2) else under the best-matching bot in the tree (shared name words, case-insensitive, or the same short role label), so related bots end up as a lead with children and a duplicate name joins its namesake; (3) else Chief's matching children are grouped under their oldest member until there is room; (4) else one level down, under the child with the fewest descendants (oldest first). Bots are placed in creation order and only reparented, never deleted; every automatic move is logged (`placed … under …`, `moved … under …`). A freed slot does not pull anyone back up. Adopted bots keep their own prompt; the tree block is added. Message rules apply to them like any tree child (Chief ↔ them; they → only Chief and their own children). - **Prompt injection:** the tree text sits in a marked block (`…`) inside each bot's `systemPrompt`; only that block is replaced, so Chief's own prompt text is never overwritten. Chief gets the coordinator rules for its direct children. - `bot.send` middleware `tree`: inside the tree a bot may message only its parent and direct children (`deny` otherwise). A non-root tree bot may not message local bots outside the tree; the root (Chief) may message any local bot. Local bots outside the tree may message only the root. Remote targets and outside-to-outside sends get no opinion (`undefined`, social decides). **Granted Bots (#90):** a friend's (remote) sender reaches a tree bot only if the core says it has a person-grant (`flybot.bot.hasAnyGrant`): new and adopted bots and the Chief start with none. A granted bot is reached directly at any depth, never redirected through the root; an ungranted one is denied (`tree: this bot has not been granted…`). Granted bots on both sides are not the tree's call across trees or to non-tree bots (the core's owner policy decides); inside one tree the parent/child rule is unchanged, as are `stopped` / `report` notices. A granted child's prompt tells it to answer outside senders directly, without a trace line, and to delegate only to its own children. On a core without `hasAnyGrant`, the remote-sender rule gives no opinion. - **Control notices bypass tree policy:** `role: 'stopped'` (stop_bot) and `role: 'report'` are never denied by the tree middleware, so a Main Bot can stop any tree bot. -- **Deleting a bot:** its children re-attach to its parent and every moved child (and its subtree) gets a fresh prompt/trace. `maxChildren` is not enforced on reparenting (no child is dropped); an overflow is logged as a warning and `create_child_bot` refuses new children there until it is back under the limit. +- **Deleting a bot:** its children re-attach to its parent and every moved child (and its subtree) gets a fresh prompt/trace. No child is dropped. If that parent is now over `maxChildren`, it is repaired automatically (#446): its matching children are grouped under their oldest member, then its newest children move down (closest match first, else the child with the fewest descendants) until it is within the limit. Each move is logged. - **`create_child_bot` is serialized per parent:** parallel calls never exceed `maxChildren` or create duplicate-name siblings. - Delegation is level by level: coordinators delegate to direct children whose scope fits and aggregate upward; a bot with no children does the work itself. A one-line trace like `Chief → Work → Dev` is added only to answers a tree bot sends to its parent tree bot; replies to the user or to a remote (friend's, Bot Social) bot never carry it, and the root (Chief) never adds one. - Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()` and `create_child_bot(name, role_description)`. The parent is always the calling bot. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Invite to this Bot (default deny). -- Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired and non-tree Bots have no parent there, so ds-bot shows them directly under Chief. The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat. +- Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; every other local Bot is placed in the tree, so the sidebar and the tree model agree. The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat. ### Config ```yaml diff --git a/plugins/tree/index.js b/plugins/tree/index.js index e61301166..e35824a97 100644 --- a/plugins/tree/index.js +++ b/plugins/tree/index.js @@ -11,7 +11,9 @@ export const inject = ['flybot'] export function apply(ctx, config = {}) { const fb = createAdapter(ctx.flybot) - const warn = (...args) => (ctx.logger?.('flybot-tree')?.warn ?? console.warn)(...args) + // Cordis gives ctx.logger as a factory; a host may give a ready logger object instead. + const logger = typeof ctx.logger === 'function' ? ctx.logger('flybot-tree') : ctx.logger + const warn = (...args) => (logger?.warn ?? console.warn)(...args) const tree = createTree(fb, { limits: resolveLimits(config), rootName: config.rootName, rootBotId: config.rootBotId, adoptExisting: config.adoptExisting !== false, warn }) const disposers = [ fb.useSend('tree', treeSendMiddleware(tree, { bypassSocial: config.bypassSocial === true, isRemote: fb.isRemote, hasAnyGrant: fb.hasAnyGrant })), @@ -21,14 +23,17 @@ export function apply(ctx, config = {}) { ] // The root is ds-bot's Main Bot (Chief). Adopt it once the team is loaded: right away, a few // retries later, and whenever a Bot is created (Chief may be created after startup). - // Each attempt also adopts unattached local bots as the root's children (config.adoptExisting). + // Each attempt also places unattached local bots in the tree (config.adoptExisting) and repairs + // over-full parents (#446). On bot.created it runs at once, inside the create call, so a Bot made + // by core create_bot is already placed by the tree's limits when that tool answers. // config.autoRoot: false turns this off; config.rootBotId picks the root explicitly. if (config.autoRoot !== false) { - const attempt = () => tree.ensureRoot().catch(error => warn('flybot-tree: could not set up the root:', error?.message ?? error)) + const failed = error => warn('flybot-tree: could not set up the root:', error?.message ?? error) + const attempt = () => { try { tree.ensureRoot().catch(failed) } catch (error) { failed(error) } } const timers = [0, 1000, 5000, 15000].map(ms => setTimeout(attempt, ms)) timers.forEach(t => t.unref?.()) disposers.push(() => timers.forEach(clearTimeout)) - disposers.push(fb.onBotCreated(() => { setTimeout(attempt, 0).unref?.() })) + disposers.push(fb.onBotCreated(() => { attempt(); setTimeout(attempt, 0).unref?.() })) } const dispose = () => { for (const d of disposers.splice(0)) d?.() } // Cordis-style cleanup: load with ctx.plugin(tree, config); ctx.effect runs the returned diff --git a/plugins/tree/src/placement.js b/plugins/tree/src/placement.js new file mode 100644 index 000000000..35da10f72 --- /dev/null +++ b/plugins/tree/src/placement.js @@ -0,0 +1,157 @@ +// Placement and repair (#446). Every local Bot that is not in the tree yet (core create_bot, the +// Bot list, an older install) is placed under the same limits create_child_bot obeys, so no create +// path goes past maxChildren or maxDepth. A full level never leaves a Bot outside the tree: +// 1. a level with room takes it; +// 2. else the best-matching Bot below that level takes it (shared name words, or the same short +// role label), so related Bots end up as a lead with children; +// 3. else the level is consolidated: siblings that match each other move under their oldest +// member (the lead) until there is room; +// 4. else it goes one level down, under the child with the fewest descendants (oldest first). +// An over-full parent (e.g. after a deletion) is repaired the same way. Nothing is ever deleted: +// Bots are only reparented, each move is logged, and the sidebar draws the same tree. +const GENERIC = new Set(['bot', 'bots', 'agent', 'agents', 'team', 'the', 'and', 'for', 'of', 'ai', 'main', 'new']) +const MAX_ROLE_LABEL = 24 // ds-bot role labels are 2-4 words; longer text is a description, not a label +const norm = s => String(s ?? '').trim().toLowerCase() + +/** Match keys of a Bot: its name's words, plus its short role label. */ +export function matchKeys({ name, role }) { + const keys = new Set(norm(name).split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !GENERIC.has(w))) + const label = norm(role) + if (label && label.length <= MAX_ROLE_LABEL) keys.add(`role:${label}`) + return keys +} +export function fitScore(a, b) { + const kb = matchKeys(b) + let n = 0 + for (const k of matchKeys(a)) if (kb.has(k)) n++ + return n +} + +export function createPlacement({ fb, limits, snapshot, childrenOf, depthOf, warn }) { + // Breadth first, siblings in creation order: shallower and older Bots come first. + const subtreeOf = (nodes, id) => { + const out = [], queue = [id], seen = new Set() + while (queue.length) { + const x = queue.shift() + if (seen.has(x) || !nodes[x]) continue + seen.add(x); out.push(x) + for (const c of childrenOf(nodes, x)) queue.push(c.id) + } + return out + } + const heightOf = (nodes, id) => { + let h = 0 + const walk = (x, d, seen) => { if (seen.has(x)) return; seen.add(x); h = Math.max(h, d); for (const c of childrenOf(nodes, x)) walk(c.id, d + 1, seen) } + walk(id, 1, new Set()) + return h + } + // x = { id, name, role, height }: the Bot to place, with the height of its own subtree. + const canHost = (nodes, hostId, x) => { + const kids = childrenOf(nodes, hostId).filter(c => c.id !== x.id) + return hostId !== x.id && kids.length < limits.maxChildren && depthOf(nodes, hostId) + x.height <= limits.maxDepth && + !kids.some(c => norm(c.name) === norm(x.name)) // never two siblings with one name (checkCreate's rule) + } + const describe = (nodes, id) => ({ id, name: nodes[id].name, role: nodes[id].role, height: heightOf(nodes, id) }) + + /** The best-matching Bot below parentId (not parentId itself, not x's own subtree) that can take x. */ + function bestMatch(nodes, parentId, x) { + const own = new Set(nodes[x.id] ? subtreeOf(nodes, x.id) : []) + let best, bestScore = 0, bestDepth = Infinity + for (const id of subtreeOf(nodes, parentId)) { + if (id === parentId || own.has(id)) continue + const score = fitScore(x, nodes[id]), depth = depthOf(nodes, id) + if (score > bestScore || (score > 0 && score === bestScore && depth < bestDepth)) { + if (!canHost(nodes, id, x)) continue + best = id; bestScore = score; bestDepth = depth + } + } + return best + } + /** One level down: the child of parentId with the fewest descendants (oldest first) that can take x, recursively. */ + function levelDown(nodes, parentId, x) { + const kids = childrenOf(nodes, parentId).filter(c => c.id !== x.id) + .map((c, i) => ({ id: c.id, size: subtreeOf(nodes, c.id).length, i })) + .sort((a, b) => (a.size - b.size) || (a.i - b.i)) + for (const k of kids) if (canHost(nodes, k.id, x)) return k.id + for (const k of kids) { const deeper = levelDown(nodes, k.id, x); if (deeper) return deeper } + return undefined + } + + const move = (id, parentId, why) => { + const nodes = snapshot() + fb.setField(id, 'parent', parentId) + warn(`flybot-tree: moved ${nodes[id]?.name ?? fb.getBot?.(id)?.name ?? id} under ${nodes[parentId]?.name ?? parentId} (${why})`) + } + + /** Moves matching children of parentId under their oldest member until it has at most `target` children. */ + function consolidate(parentId, target) { + for (let guard = 0; guard < 64; guard++) { + const nodes = snapshot() + const kids = childrenOf(nodes, parentId) + if (kids.length <= target) return true + const groups = new Map() + for (const c of kids) for (const k of matchKeys(c)) { if (!groups.has(k)) groups.set(k, []); groups.get(k).push(c) } + const ordered = [...groups.values()].filter(g => g.length > 1).sort((a, b) => b.length - a.length) + let moved = false + for (const group of ordered) { + for (const lead of group) { + const x = [...group].reverse().find(c => c.id !== lead.id && canHost(nodes, lead.id, describe(nodes, c.id))) + if (!x) continue + move(x.id, lead.id, `${nodes[parentId].name} was over maxChildren=${limits.maxChildren}; grouped with ${lead.name}`) + moved = true + break + } + if (moved) break + } + if (!moved) return false + } + return childrenOf(snapshot(), parentId).length <= target + } + + /** Where x goes when it should be under parentId: parentId itself, or a Bot below it. undefined when the whole subtree is full. */ + function homeFor(parentId, x) { + let nodes = snapshot() + if (canHost(nodes, parentId, x)) return { id: parentId } + const match = bestMatch(nodes, parentId, x) + if (match) return { id: match, why: `closest match for ${x.name}` } + if (consolidate(parentId, limits.maxChildren - 1)) { + nodes = snapshot() + if (canHost(nodes, parentId, x)) return { id: parentId } + } + nodes = snapshot() + const down = levelDown(nodes, parentId, x) + return down ? { id: down, why: `${nodes[parentId].name} is full (maxChildren=${limits.maxChildren}); no closer match` } : undefined + } + + /** Places a Bot that is not in the tree yet. Returns the new parent id, or undefined if nothing can take it. */ + function place(bot, rootId) { + const role = String(bot.role ?? '').trim() + const home = homeFor(rootId, { id: bot.id, name: bot.name, role, height: 1 }) + if (!home) return undefined + fb.setField(bot.id, 'treeRole', role) + fb.setField(bot.id, 'parent', home.id) + if (home.why) warn(`flybot-tree: placed ${bot.name} under ${snapshot()[home.id].name} (${home.why})`) + return home.id + } + + /** Brings every over-full parent back to maxChildren by reparenting its newest children. Returns true if anything moved. */ + function repair() { + let changed = false + for (let guard = 0; guard < 256; guard++) { + const nodes = snapshot() + const over = Object.keys(nodes).find(id => childrenOf(nodes, id).length > limits.maxChildren) + if (!over) return changed + if (consolidate(over, limits.maxChildren)) { changed = true; continue } + const now = snapshot() + const kids = childrenOf(now, over) + const x = describe(now, kids[kids.length - 1].id) + const home = bestMatch(now, over, x) ?? levelDown(now, over, x) + if (!home) { warn(`flybot-tree: ${now[over].name} has ${kids.length} children (maxChildren=${limits.maxChildren}) and no Bot below it can take more; kept all`); return changed } + move(x.id, home, `${now[over].name} was over maxChildren=${limits.maxChildren}`) + changed = true + } + return changed + } + + return { place, repair } +} diff --git a/plugins/tree/src/prompt.js b/plugins/tree/src/prompt.js index 44a0887f8..0e03bcba6 100644 --- a/plugins/tree/src/prompt.js +++ b/plugins/tree/src/prompt.js @@ -15,22 +15,24 @@ export function systemPrompt({ name, role, trace, children, root = false }) { ? [`Bot tree: you (${name}) are also the ROOT of a recursive bot tree of child bots. For tasks you delegate into the tree, follow these tree rules.`, `Your position: ${trace}`] : [`You are ${name}, a bot in a recursive bot tree. Your role: ${role || '(general)'}`, `Your position: ${trace}`] const tools = 'Tree tools: list_children() shows your current direct children (it may be newer than this prompt). create_child_bot(name, role_description) creates a child directly under you.' + // #446: create_bot is a Main Bot tool; the tree places what it makes by the same limits. + const limitRule = 'The tree limits (maxChildren, maxDepth) apply to every way of creating a bot. Do not use create_bot to get around them: a bot made with create_bot is placed in this tree by the same limits, under the closest matching bot when your level is full.' if (!children.length) { return [...head, '', 'You have no children: do the work yourself and answer concisely with concrete results.', 'Only if the task clearly splits into a separate domain you cannot cover, you may create a child with create_child_bot and delegate to it.', - 'You may message only your parent and your direct children.', ...(root ? [] : [OUTSIDE_RULE]), tools, + 'You may message only your parent and your direct children.', ...(root ? [limitRule] : [OUTSIDE_RULE]), tools, traceRule(trace, root)].join('\n') } return [...head, '', 'You are a COORDINATOR. Your direct children (the only bots you may delegate to; you cannot see deeper levels):', ...children.map(c => `- ${c.name}: ${c.role || ''}`), '', '1. Split the request into parts. Delegate a part only to a child whose stated scope fits it; generic abilities do not make a child fit another domain. Do not plan for grandchildren: each child decomposes its own part.', - '2. Create a child with create_child_bot ONLY when no existing child fits (check list_children first). Never create duplicates or overlapping scopes. If creation is rejected by limits, use the closest child.', + '2. Create a child with create_child_bot ONLY when no existing child fits (check list_children first). Never create duplicates or overlapping scopes. If creation is rejected by limits, delegate the part to the closest child; it can create a child under itself.', '3. Send each child only its own self-contained part, all in parallel, then wait.', '4. Aggregate the answers into one concise answer for your parent (or the user). If only one child answered, pass its answer through unchanged.', `5. ${traceRule(trace, root)}`, - 'You may message only your parent and your direct children.', ...(root ? [] : [OUTSIDE_RULE]), tools].join('\n') + 'You may message only your parent and your direct children.', ...(root ? [limitRule] : [OUTSIDE_RULE]), tools].join('\n') } // The tree's text is kept in a marked block inside the bot's systemPrompt, so text written by the diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index b0636c66d..5e06f4309 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -6,6 +6,7 @@ import { checkCreate } from './limits.js' import { systemPrompt, withTreeBlock, hasTreeBlock } from './prompt.js' import { forestOf } from './forest.js' +import { createPlacement } from './placement.js' export { forestOf } export const TREE_KEYS = new Set(['parent', 'treeRole']) @@ -41,6 +42,9 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis if (next !== String(current ?? '').trim()) fb.setSystemPrompt(id, next) } const refreshPrompt = (id, nodes = snapshot()) => { if (nodes[id]) writeBlock(id, promptFor(id, nodes)) } + // After a placement or repair any trace or child list may have changed; writeBlock skips unchanged prompts. + const refreshAll = () => { const nodes = snapshot(); for (const id of Object.keys(nodes)) refreshPrompt(id, nodes) } + const placement = createPlacement({ fb, limits, snapshot, childrenOf, depthOf, warn }) // ---------------------------------------------------------------- root /** The Main Bot (Chief): config.rootBotId if it exists, else the Main Bot named 'Chief', else the first Main Bot. */ @@ -77,14 +81,16 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis if (fb.getField(chief.id, 'treeRole') !== ROOT_ROLE) fb.setField(chief.id, 'treeRole', ROOT_ROLE) migrateLegacyRoots(chief.id) if (adoptExisting) adoptUnattached(chief.id) - const nodes = snapshot() - for (const id of [chief.id, ...childrenOf(nodes, chief.id).map(c => c.id)]) refreshPrompt(id, nodes) + placement.repair() + refreshAll() return chief.id } /** - * Every local bot with no tree fields (and not retired) becomes a child of the root, up to - * maxChildren; the rest stay unattached with a warning. Remote bots are never in bot.list(). + * Every local bot with no tree fields (and not retired) is placed in the tree by the tree's limits + * (placement.js): under the root while it has room, else under the closest match, after grouping + * matching siblings, or one level down. A bot is left outside only when the whole tree is full. + * Remote bots are never in bot.list(). Bots are placed in list (creation) order. */ function adoptUnattached(rootId) { const adopted = [], skipped = [] @@ -93,13 +99,11 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis if (fb.getField(b.id, 'retired') === true) continue if (fb.getField(b.id, 'parent') !== undefined || fb.getField(b.id, 'treeRole') !== undefined) continue if (b.isMain === true && !rootBotId) continue // another Main Bot stays outside the tree - if (childrenOf(snapshot(), rootId).length >= limits.maxChildren) { skipped.push(b.name); continue } - fb.setField(b.id, 'treeRole', String(b.role ?? '').trim()) - fb.setField(b.id, 'parent', rootId) - adopted.push(b.id) + if (placement.place(b, rootId) === undefined) skipped.push(b.name) + else adopted.push(b.id) } - if (skipped.length) warn(`flybot-tree: ${skipped.length} bot(s) left outside the tree because the root already has maxChildren=${limits.maxChildren} children: ${skipped.join(', ')}`) - if (adopted.length) { const nodes = snapshot(); for (const id of adopted) refreshPrompt(id, nodes) } + if (skipped.length) warn(`flybot-tree: ${skipped.length} bot(s) left outside the tree because every Bot in it is at maxChildren=${limits.maxChildren} or maxDepth=${limits.maxDepth}: ${skipped.join(', ')}`) + if (adopted.length) refreshAll() return { adopted, skipped } } @@ -188,31 +192,17 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis emit(change) } /** - * After deletion: the removed bot's children re-attach to its parent (from the event's last record). - * Every reparented child and its descendants get a fresh prompt (their trace no longer names the - * deleted bot). maxChildren is NOT enforced here: deleting a bot never orphans or drops its - * children; if the new parent ends up over the limit we only warn, and create_child_bot keeps - * refusing new children there until it is back under the limit. + * After deletion: the removed bot's children re-attach to its parent (from the event's last record), + * so no child is dropped. If that parent is now over maxChildren, placement.repair() moves its + * newest children down (closest match first) until it is back within the limit (#446). Every + * moved Bot and its descendants get a fresh prompt (their trace no longer names the deleted bot). */ function onBotRemoved({ botId, parent }) { if (parent === undefined) return // not a tree bot const up = parent ?? null - const moved = childrenOf(snapshot(), botId) - for (const child of moved) fb.setField(child.id, 'parent', up) - const nodes = snapshot() - if (up && nodes[up]) { - refreshPrompt(up, nodes) - const count = childrenOf(nodes, up).length - if (count > limits.maxChildren) warn(`flybot-tree: ${nodes[up].name} now has ${count} children after a deletion (maxChildren=${limits.maxChildren}); kept all, new children are refused until it is under the limit`) - } - const stack = moved.map(c => c.id), seen = new Set() - while (stack.length) { - const id = stack.pop() - if (seen.has(id)) continue - seen.add(id) - refreshPrompt(id, nodes) - for (const c of childrenOf(nodes, id)) stack.push(c.id) - } + for (const child of childrenOf(snapshot(), botId)) fb.setField(child.id, 'parent', up) + placement.repair() + refreshAll() emit({ botId, key: 'removed' }) } const subscribe = fn => { listeners.add(fn); return () => listeners.delete(fn) } diff --git a/plugins/tree/test/host.test.js b/plugins/tree/test/host.test.js index 04d5c62d7..35148e231 100644 --- a/plugins/tree/test/host.test.js +++ b/plugins/tree/test/host.test.js @@ -138,3 +138,24 @@ test('#90 on the real host: a friend reaches a granted tree Bot directly, never await h.stop() } }) + +test('#446 on the real host: core create_bot cannot get past maxChildren; the new Bot is placed in the tree before the tool answers', async () => { + const h = await start() + try { + const fork = h.ctx.plugin(tree, { maxChildren: 1 }) + const tf = h.fb.scope('flybot-tree').fields + await waitFor(() => tf.get('b', 'parent') === 'a') // Writer fills Chief's only slot + assert.match(JSON.parse(await h.tool('create_child_bot', { name: 'Editor', role_description: 'edit' }, 'a')).error, /limit reached: Chief already has 1 children \(maxChildren=1\)/) + const answer = await h.tool('create_bot', { name: 'Editor', role: 'Edit', instructions: 'Edit texts' }, 'a') + assert.match(String(answer), /Created Editor/) + const editor = h.fb.bot.list().find(b => b.name === 'Editor') + assert.ok(editor, 'create_bot made the Bot') + assert.equal(tf.get(editor.id, 'parent'), 'b', 'placed one level down, under Writer, right away') + assert.equal(h.fb.bot.list().filter(b => tf.get(b.id, 'parent') === 'a').length, 1, 'Chief stays within maxChildren') + assert.match(h.fb.bot.get('b').systemPrompt, /COORDINATOR[\s\S]*- Editor/) + assert.match(h.fb.bot.get('a').systemPrompt, /Do not use create_bot to get around them/) + fork.dispose() + } finally { + await h.stop() + } +}) diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 072cdb16f..f1ec5c19d 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -598,7 +598,7 @@ test('client forestOf is an identical copy of src/forest.js', async () => { assert.deepEqual(forestOf([{ id: 'a', name: 'A', fields: { 'flybot-tree': { parent: 'a', treeRole: 'x' } } }]).map(n => n.id), ['a']) // self-parent -> top level }) -test('adoption: existing local bots without tree fields become children of Chief; retired/remote/main skipped; maxChildren respected', async () => { +test('adoption: existing local bots without tree fields join the tree under Chief; retired/remote/main skipped; maxChildren respected (#446)', async () => { const m = createMockFlybot() const chief = m.addBot({ name: 'Chief', isMain: true }) const names = ['研究员', '数据分析师', '文案', '程序员'] @@ -610,8 +610,9 @@ test('adoption: existing local bots without tree fields become children of Chief await tree.ensureRoot() const kids = (await m.call(chief, 'list_children')).children.map(c => c.name) assert.deepEqual(kids, ['研究员', '数据分析师', '文案']) // maxChildren=3 - assert.equal(f(m, ids[3], 'parent'), undefined) // left unattached - assert.match(warns.join('\n'), /1 bot\(s\) left outside the tree[\s\S]*maxChildren=3[\s\S]*程序员/) + assert.equal(f(m, ids[3], 'parent'), ids[0]) // Chief is full and nothing matches: one level down, fewest descendants, oldest first + assert.doesNotMatch(warns.join('\n'), /left outside/) + assert.match(warns.join('\n'), /placed 程序员 under 研究员 \(Chief is full \(maxChildren=3\)/) assert.equal(f(m, retired, 'parent'), undefined); assert.equal(f(m, main2, 'parent'), undefined) assert.match(sp(m, ids[0]), /^研究员 own\n\n/) // own prompt kept, tree block added assert.match(sp(m, chief), /COORDINATOR[\s\S]*- 研究员/) @@ -620,7 +621,8 @@ test('adoption: existing local bots without tree fields become children of Chief assert.equal((await send(chief, ids[0])).action, 'allow') assert.equal((await send(ids[0], chief)).action, 'allow') assert.equal((await send(ids[0], ids[1])).action, 'deny') // siblings - assert.equal((await send(ids[0], ids[3])).action, 'deny') // tree bot -> local non-tree bot + assert.equal((await send(ids[0], ids[3])).action, 'allow') // 程序员 is now 研究员's child + assert.equal((await send(ids[1], ids[3])).action, 'deny') // not its parent // idempotent; host-side sync forest await tree.ensureRoot() assert.equal((await m.call(chief, 'list_children')).children.length, 3) @@ -818,7 +820,7 @@ test('issue 15 (1): control notices (stopped, report) bypass tree policy; a Main assert.equal((await send(o, dev)).action, 'deny') }) -test('issue 15 (2): deleting a middle node refreshes every reparented child (and its subtree); overflow warns', async () => { +test('issue 15 (2) / #446: deleting a middle node refreshes every reparented child (and its subtree); an overflow is repaired, nothing dropped', async () => { const warnings = [] const m = createMockFlybot() m.addBot({ name: 'Chief', isMain: true, systemPrompt: '' }) @@ -827,11 +829,18 @@ test('issue 15 (2): deleting a middle node refreshes every reparented child (and const work = await make(m, m.root, 'Work'); await make(m, m.root, 'Life') const dev = await make(m, work, 'Dev'); const ops = await make(m, work, 'Ops'); const qa = await make(m, dev, 'QA') assert.match(sp(m, dev), /Work/); assert.match(sp(m, qa), /Work/) + const life = m.tree.childrenOf(m.tree.snapshot(), m.root).find(c => c.name === 'Life').id m.removeBot(work) - for (const id of [dev, ops]) assert.equal(f(m, id, 'parent'), m.root) - for (const id of [dev, ops, qa]) { assert.doesNotMatch(sp(m, id), /Work/); assert.match(sp(m, id), /Chief/) } + // Chief would have Life, Dev and Ops (3 > maxChildren=2): the newest (Ops) moves one level down, + // under the child with the fewest descendants (Life; Dev has QA). No Bot is deleted. + assert.equal(f(m, dev, 'parent'), m.root) + assert.equal(f(m, ops, 'parent'), life) + assert.equal(m.tree.childrenOf(m.tree.snapshot(), m.root).length, 2) + for (const id of [dev, ops, qa]) { assert.ok(m.bots.has(id)); assert.doesNotMatch(sp(m, id), /Work/); assert.match(sp(m, id), /Chief/) } assert.match(sp(m, qa), /Chief → Dev → QA/) - assert.ok(warnings.some(w => /maxChildren=2/.test(w)), 'overflow is logged') + assert.match(sp(m, ops), /Chief → Life → Ops/) + assert.match(sp(m, life), /COORDINATOR[\s\S]*- Ops/) + assert.ok(warnings.some(w => /moved Ops under Life \(Chief was over maxChildren=2\)/.test(w)), 'the move is logged') await assert.rejects(m.call(m.root, 'create_child_bot', { name: 'New', role_description: 'x' }).then(r => { if (r.error || !r.created) throw new Error(r.error ?? 'refused') })) }) @@ -914,3 +923,77 @@ test('adapter refuses a flybot API version it was not written for', async () => const m = createMockFlybot() assert.throws(() => createAdapter({ ...m.flybot, scope: m.flybot.scope, version: FLYBOT_API + 1 }), /needs flybot API v1/) }) + +// #446: core create_bot (here: flybot.bot.create with no tree fields, which is what it ends in) must +// not get past maxChildren; a full level is reorganized automatically, never left outside the tree. +async function fullChief(maxChildren = 3, names = ['Cooking', 'Travel', 'Garden']) { + const m = createMockFlybot() + const chief = m.addBot({ name: 'Chief', isMain: true }) + const warns = [] + const { tree } = plugin.apply({ flybot: m.flybot, logger: () => ({ warn: (...a) => warns.push(a.join(' ')) }) }, { maxChildren }) + await tree.ensureRoot() + const kids = {} + for (const name of names) kids[name] = (await m.call(chief, 'create_child_bot', { name, role_description: `${name} stuff` })).created.id + return { m, tree, chief, kids, warns, children: id => tree.childrenOf(tree.snapshot(), id).map(c => c.name) } +} + +test('#446: create_child_bot still refuses at maxChildren; create_bot Bots are placed by the same limits at once', async () => { + const { m, chief, kids, warns, children } = await fullChief() + assert.match((await m.call(chief, 'create_child_bot', { name: 'FlyBot Dev', role_description: 'x' })).error, /limit reached: Chief already has 3 children \(maxChildren=3\)/) + const team = [] + for (const name of ['FlyBot Dev', 'FlyBot Test', 'FlyBot Maint', 'FlyBot Repo']) { + const id = await m.flybot.bot.create({ name }) // the create_bot path: no tree fields + assert.ok(f(m, id, 'parent'), `${name} is in the tree as soon as create returns`) + team.push(id) + } + assert.deepEqual(children(chief), ['Cooking', 'Travel', 'Garden']) // still within maxChildren + // The first goes one level down (no match, nothing to group); the rest join it: a lead with children. + assert.equal(f(m, team[0], 'parent'), kids.Cooking) + for (const id of team.slice(1)) assert.equal(f(m, id, 'parent'), team[0]) + assert.deepEqual(children(team[0]), ['FlyBot Test', 'FlyBot Maint', 'FlyBot Repo']) + assert.match(sp(m, team[0]), /COORDINATOR[\s\S]*- FlyBot Test/) + assert.match(warns.join('\n'), /placed FlyBot Test under FlyBot Dev \(closest match for FlyBot Test\)/) +}) + +test('#446: a full level is consolidated (matching siblings under their oldest member) before a newcomer goes down a level', async () => { + const { m, chief, kids, warns, children } = await fullChief(3, ['Docs Writer', 'Code Writer', 'Travel']) + const cooking = await m.flybot.bot.create({ name: 'Cooking' }) + assert.equal(f(m, kids['Code Writer'], 'parent'), kids['Docs Writer']) + assert.equal(f(m, cooking, 'parent'), chief) + assert.deepEqual(children(chief), ['Docs Writer', 'Travel', 'Cooking']) + assert.match(warns.join('\n'), /moved Code Writer under Docs Writer \(Chief was over maxChildren=3; grouped with Docs Writer\)/) + assert.match(sp(m, kids['Code Writer']), /Chief → Docs Writer → Code Writer/) +}) + +test('#446: a duplicate-named Bot joins its namesake instead of becoming a second sibling with that name', async () => { + const { m, chief, kids, children } = await fullChief(8, ['Editor', 'Travel']) + const dup = await m.flybot.bot.create({ name: 'editor' }) + assert.equal(f(m, dup, 'parent'), kids.Editor) + assert.deepEqual(children(chief), ['Editor', 'Travel']) +}) + +test('#446: freeing a slot under Chief moves nobody up by list order; placement is deterministic', async () => { + const { m, tree, chief, kids, children } = await fullChief() + const dev = await m.flybot.bot.create({ name: 'FlyBot Dev' }) + const test2 = await m.flybot.bot.create({ name: 'FlyBot Test' }) + const before = Object.fromEntries(Object.values(tree.snapshot()).map(n => [n.id, n.parent])) + m.removeBot(kids.Garden) + await tree.ensureRoot() + assert.deepEqual(children(chief), ['Cooking', 'Travel']) + assert.equal(f(m, dev, 'parent'), before[dev]); assert.equal(f(m, test2, 'parent'), dev) + // The next new Bot takes the free slot itself. + const next = await m.flybot.bot.create({ name: 'Music' }) + assert.equal(f(m, next, 'parent'), chief) +}) + +test('#446: placement and repair keep every parent within maxChildren and every Bot within maxDepth', async () => { + const { m, tree, chief } = await fullChief(2, ['A1', 'B1']) + for (let i = 0; i < 12; i++) await m.flybot.bot.create({ name: `Extra ${i}` }) + const nodes = tree.snapshot() + assert.equal(Object.keys(nodes).length, 15) // Chief + 2 + 12: nobody left outside + for (const id of Object.keys(nodes)) { + assert.ok(tree.childrenOf(nodes, id).length <= 2, `${nodes[id].name} within maxChildren`) + assert.ok(tree.depthOf(nodes, id) <= 5, `${nodes[id].name} within maxDepth`) + } + assert.equal(nodes[chief].parent, null) +}) From 2002be355d1fa83c0ab237bfb4dfd0f36b9c5c85 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:50:09 +0800 Subject: [PATCH 2/2] Tree organizes itself by role: groups, leads, merge, daily tidy, undo Owner decision 2026-10-19 21:27 (Asia/Shanghai) on #446: no "least-loaded child" fallback. A Bot made outside the tree (core create_bot, the Bot list) is placed into the group that matches its role (name, role label, description, own prompt, recent tools): its namesake, else the Bot that leads that group, else the root. A full level is regrouped by role under a suitable existing member (already its lead, coordinating it, or named for it) or under a new lead with a clear role ("FlyBot Lead", "Life Lead"); a Bot that fits no group stays outside, logged and tagged, never under an unrelated Bot. No parent goes over maxChildren at any moment; repair after a deletion uses the same rules. A daily tidy groups related Bots, merges duplicates (the old Bot archived, never deleted) and marks long-idle leaf Bots idle, archiving them after 30 days. Every move, lead, merge and archive is journaled on the root and logged; the sidebar shows the last run with Undo / Dismiss (tree/reorg, tree/undo, tree/dismiss), tags idle / archived / outside Bots, and the Main Bot's prompt names the last run. Role matching is deterministic, with a config.classify hook; moving memory on a merge is a follow-up. Fixes #446 --- README.md | 6 +- README.zh.md | 6 +- changelog.d/446-tree-auto-placement.fixed.md | 1 - changelog.d/446-tree-self-organize.fixed.md | 1 + docs/subsystems/tree.md | 25 +- plugins/tree/README.md | 19 +- plugins/tree/client.js | 110 +++- plugins/tree/index.js | 23 +- plugins/tree/src/adapter.js | 5 + plugins/tree/src/organize.js | 513 +++++++++++++++++++ plugins/tree/src/placement.js | 157 ------ plugins/tree/src/prompt.js | 10 +- plugins/tree/src/roles.js | 127 +++++ plugins/tree/src/tree.js | 65 ++- plugins/tree/test/host.test.js | 32 +- plugins/tree/test/mock-flybot.js | 9 +- plugins/tree/test/tree.test.js | 346 ++++++++++--- plugins/tree/test/web-entry.test.js | 5 +- 18 files changed, 1171 insertions(+), 289 deletions(-) delete mode 100644 changelog.d/446-tree-auto-placement.fixed.md create mode 100644 changelog.d/446-tree-self-organize.fixed.md create mode 100644 plugins/tree/src/organize.js delete mode 100644 plugins/tree/src/placement.js create mode 100644 plugins/tree/src/roles.js diff --git a/README.md b/README.md index 73683346c..91532732b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Turns your Bots into one recursive tree that splits a request into parts, sends *Demo: Chief starts alone in the sidebar, creates the child Bots Travel, Food and Budget, hands each one a part, and then combines their answers into one plan. Some waiting is sped up.* [Watch the full video (MP4)](docs/images/demo-tree.mp4) -- **One root.** The tree's root is the Main Bot (Chief); no new bot is created for it. A separate `Root` bot is created only if the team has Bots but no Main Bot. Every other local Bot joins the tree, including one made with `create_bot` or the Bot list, under the same limits as `create_child_bot`. When a level is full, the tree reorganizes itself instead of leaving a Bot outside: the new Bot goes under the best-matching Bot (shared name words), matching siblings are grouped under their oldest member, or it moves one level down. Bots are only moved, never deleted, and each move is logged. +- **One root.** The tree's root is the Main Bot (Chief); no new bot is created for it. A separate `Root` bot is created only if the team has Bots but no Main Bot. Every other local Bot joins the tree, including one made with `create_bot` or the Bot list, under the same limits as `create_child_bot`. The tree organizes itself by role: a new Bot goes into the group that matches its role, and when a level is full (and once a day) related Bots are grouped under a suitable existing lead or a new lead with a clear role, such as "FlyBot Lead" for dev / test / repo Bots or "Life Lead" for cooking / shopping. Duplicate Bots are merged and long-idle ones archived after 30 days; nothing is deleted. Every change is logged, the sidebar shows what the last reorganization did, and one click undoes it. - **Children on demand.** Any tree Bot (not only the Main/root) calls `create_child_bot(name, role_description)` to make a child directly under *itself*; the tool takes no parent argument. Calls are handled one at a time per parent, so parallel calls never go over the limits or create two siblings with the same name. The rules: - names are 1–40 characters, and duplicate sibling names are rejected (case and spaces ignored); - depth is at most 5 (the root counts as 1), with at most 8 children per Bot (set in `limits.json` or the plugin config); @@ -49,10 +49,10 @@ Turns your Bots into one recursive tree that splits a request into parts, sends - the root may also message any local Bot; - local Bots outside the tree may message only the root; - `stopped` and `report` notices are never blocked, so Chief can always stop any Bot. -- **Reparenting.** When a Bot is deleted, its children move up to its parent. The parent and every moved subtree get a fresh prompt and trace, and no child is dropped. If the parent is then over `maxChildren`, its newest children move one level down the same way. +- **Reparenting.** When a Bot is deleted, its children move up to its parent. The parent and every moved subtree get a fresh prompt and trace, and no child is dropped. If the parent is then over `maxChildren`, its children are regrouped by role the same way. - **Trace line.** When a tree Bot answers its *parent*, it starts the answer with its path, e.g. `Chief → Work → Dev`. Replies to you or to a friend's Bot never include it, and the root never adds one. - **Your prompts are kept.** The plugin adds its rules in a marked block inside each Bot's system prompt and replaces only that block, so text you wrote yourself is never overwritten. -- **Sidebar.** The Bot list is drawn as one expandable tree with Chief on top. Retired Bots and other Main Bots are shown directly under Chief; every other Bot is drawn where the tree has it. +- **Sidebar.** The Bot list is drawn as one expandable tree with Chief on top. Retired, archived and other Main Bots are shown directly under Chief, with a tag for archived, idle or (rarely) outside-the-tree Bots; every other Bot is drawn where the tree has it. Install (needs the core, `path:/core`): diff --git a/README.zh.md b/README.zh.md index 658df6a30..8c9414139 100644 --- a/README.zh.md +++ b/README.zh.md @@ -31,7 +31,7 @@ FlyBot 是在固定版本的 [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) *演示:侧栏里一开始只有 Chief;它建出子 Bot Travel、Food、Budget,各分一块,再把答案汇总成一份行程。等待的部分做了加速。* [观看完整视频(MP4)](docs/images/demo-tree.mp4) -- **一个根。** 树的根是主 Bot(Chief),不会再新建一个根。只有团队里有 Bot、却没有主 Bot 时,才会单独建一个 `Root`。其他本地 Bot(包括用 `create_bot` 或 Bot 列表建的)都会进树,遵守和 `create_child_bot` 一样的限制。某一层满了时,树会自动整理,而不是把 Bot 留在树外:新 Bot 挂到名字最匹配的 Bot 下(名称有相同的词),或把互相匹配的同级 Bot 归到其中最早的那个下面,或下移一层。Bot 只会被移动,从不删除,每次移动都记日志。 +- **一个根。** 树的根是主 Bot(Chief),不会再新建一个根。只有团队里有 Bot、却没有主 Bot 时,才会单独建一个 `Root`。其他本地 Bot(包括用 `create_bot` 或 Bot 列表建的)都会进树,遵守和 `create_child_bot` 一样的限制。树会按角色自我整理:新 Bot 进入角色匹配的分组;某一层满了时(以及每天一次),角色相近的 Bot 归到一个合适的现有负责人下,或新建一个职责明确的负责人(例如开发 / 测试 / 仓库维护归到 "FlyBot Lead",做饭 / 购物归到 "Life Lead")。重复的 Bot 会合并,长期闲置的 Bot 30 天后归档;从不删除。每次改动都记日志,侧栏会显示上一次整理做了什么,一键即可撤销。 - **按需生子。** 树中的任意 Bot(不只是主 Bot / 根)调用 `create_child_bot(name, role_description)`,在**自己**下面直接建子 Bot;工具没有 parent 参数。同一父节点的调用串行处理,避免超限或重名。规则: - 名称 1–40 个字符;同级重名会被拒绝(忽略大小写和空格); - 深度最多 5(根算第 1 层),每个 Bot 最多 8 个子节点(见 `limits.json` 或插件配置); @@ -44,10 +44,10 @@ FlyBot 是在固定版本的 [DS Bot](https://github.com/Fei2411/DeepSeek-Bot) - 根还可以给任意本地 Bot 发消息; - 树外的本地 Bot 只能给根发消息; - `stopped` 与 `report` 通知永不拦截,Chief 总能叫停任意 Bot。 -- **换父。** 删除某个 Bot 时,其子节点上移到它的父节点;父节点与整棵子树会刷新提示词与路径,子节点不会丢失。如果父节点因此超过 `maxChildren`,最新的子节点按同样的规则下移一层。 +- **换父。** 删除某个 Bot 时,其子节点上移到它的父节点;父节点与整棵子树会刷新提示词与路径,子节点不会丢失。如果父节点因此超过 `maxChildren`,其子节点按同样的角色规则重新分组。 - **路径行。** 树中 Bot **向父节点**汇报时,答案以路径开头,例如 `Chief → Work → Dev`。回给你或好友 Bot 时不加路径;根也不会加。 - **保留你的提示词。** 插件把规则写在系统提示词里的标记块中,只替换该块,不会覆盖你自己写的内容。 -- **侧栏。** Bot 列表画成可展开的树,Chief 在顶上;已退役的 Bot 和其他主 Bot 直接挂在 Chief 下;其余 Bot 都画在树里它所在的位置。 +- **侧栏。** Bot 列表画成可展开的树,Chief 在顶上;已退役、已归档的 Bot 和其他主 Bot 直接挂在 Chief 下,归档、闲置或(少见的)树外 Bot 带有标签;其余 Bot 都画在树里它所在的位置。 安装(需要先装核心 `path:/core`): diff --git a/changelog.d/446-tree-auto-placement.fixed.md b/changelog.d/446-tree-auto-placement.fixed.md deleted file mode 100644 index 8affb25f4..000000000 --- a/changelog.d/446-tree-auto-placement.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Bots made outside the tree (Chief's `create_bot`, the Bot list) now always join the tree under the same `maxChildren` and `maxDepth` limits as `create_child_bot`. When a level is full, the tree reorganizes itself instead of leaving the Bot outside while the sidebar showed it under Chief: the Bot goes under the best-matching Bot, matching siblings are grouped under a lead, or it moves one level down. A parent left over the limit after a deletion is repaired the same way. Bots are only moved, never deleted, and every move is logged ([#446](https://github.com/ThinkFlowLab/FlyBot/issues/446)). diff --git a/changelog.d/446-tree-self-organize.fixed.md b/changelog.d/446-tree-self-organize.fixed.md new file mode 100644 index 000000000..d0fb2ddcb --- /dev/null +++ b/changelog.d/446-tree-self-organize.fixed.md @@ -0,0 +1 @@ +Bots made outside the tree (Chief's `create_bot`, the Bot list) now always join the tree under the same `maxChildren` and `maxDepth` limits as `create_child_bot`, instead of staying outside while the sidebar showed them under Chief. The tree organizes itself by role: a new Bot goes into the group that matches its role, and when a level is full, and once a day, related Bots are grouped under a suitable existing lead or a new lead with a clear role (for example "FlyBot Lead" or "Life Lead"); it never puts a Bot under an unrelated one. The daily pass also merges duplicate Bots and marks long-idle ones idle, archiving them after 30 days; nothing is deleted. Every change is logged, the sidebar shows what the last reorganization did with one-click Undo, and Chief knows about it ([#446](https://github.com/ThinkFlowLab/FlyBot/issues/446)). diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index ac2fe6f99..92cbb7c76 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -23,7 +23,8 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). | File | Role | | --- | --- | | `plugins/tree/index.js` | Plugin entry. | -| `plugins/tree/src/placement.js` | Automatic placement and repair under `maxChildren` / `maxDepth` (#446). | +| `plugins/tree/src/organize.js` | Self-organization (#446): placement by role, role groups and leads, repair, daily tidy (merge, idle, archive), journal and undo. | +| `plugins/tree/src/roles.js` | Role profiles and similarity (name, role label, description, own prompt, recent tools; deterministic, `classify` hook). | ## Entry points @@ -39,18 +40,22 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). - Every tree member (not only the Main/root) may call `create_child_bot`. The parent is always the calling Bot; the tool has no parent argument. - `maxChildren` and depth limits stay. Parallel creates are serialized per parent. -- `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 `src/placement.js` synchronously on `bot.created`, so it is in the tree before `create_bot` answers. `create_child_bot` keeps its contract (directly under the caller, `limit reached` when full). +- `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 contract (directly under the caller, `limit reached` when full). - 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. -### Automatic placement and repair (#446) +### Self-organization (#446, owner decision 2026-10-19 21:27) -The tree reorganizes itself instead of leaving Bots outside it or a parent over the limit. This is the safety net; smarter allocation up front is [#447](https://github.com/ThinkFlowLab/FlyBot/issues/447). +The tree organizes itself by role instead of leaving Bots outside it or a parent over the limit. Allocation up front (reuse before create, `create_child_bot(parent?)`) is [#447](https://github.com/ThinkFlowLab/FlyBot/issues/447), which builds on these primitives. -- **Placement** of a Bot outside the tree (creation order, deterministic): (1) under the root while it has room; (2) else under the best-matching tree Bot that has room (shared name words, case-insensitive, generic words like "bot" or "team" ignored, or the same short role label of at most 24 characters; ties go to the shallower, then older Bot), which gives a lead with children and puts a duplicate name under its namesake; (3) else the root is consolidated: its matching children move under their oldest member until there is room; (4) else one level down, under the child with the fewest descendants (oldest first), recursively. -- **Repair**: any parent over `maxChildren` (e.g. after a deletion re-attached a deleted Bot's children) is consolidated, then its newest children move down by (2)/(4) until it is within the limit. It runs on every root attempt and after every deletion. -- **Never destructive**: Bots are only reparented. No Bot is deleted, merged into another or retired; sessions, prompts and grants are untouched. Every automatic move is logged (`flybot-tree: placed … under …` / `moved … under …`). A subtree is moved only where its whole height fits `maxDepth`, and never next to a sibling with the same name. -- **No silent pull-up**: freeing a slot moves nobody up; the next new Bot takes it. -- The sidebar draws the same tree: only retired Bots and other Main Bots have no parent there. +- **Role profile** (`src/roles.js`, deterministic): name words (weighted most), the ds-bot role label, the tree role (description), the Bot's own prompt text (not the tree block) and the tools it used recently (`activity.tools`, from the `tool.start` feed; common tools such as `message_bot` ignored). They give group keys, most specific first: `project:` (a name word that is not a domain word, e.g. "flybot"), `domain:` (dev, life, writing, research, finance, design, business, learning; English stems and Chinese terms), `word:` (a shared role word) and `area:` (labels Work / Personal / General). The plugin has no model call (the codebase has no pattern for a plugin to call a Bot's model); `config.classify(profile)` may return `{ domain, project }` to override it. +- **Placement** of a Bot outside the tree: its namesake, else the tree Bot that leads a group it belongs to (most specific key first, then the shallower lead; an area only through a lead made for it, e.g. "Work Lead"), else the root. At the first of those with room it is placed. A full level is **regrouped**: the most specific group of two or more children (the newcomer counted) moves under a suitable existing member, or under a new lead. Inside a full lead of its own group with no finer group, the member closest to its role takes it (all members share the group). There is **no "least-loaded child" fallback**: a Bot that fits no group while every level it matches is full stays outside, a warning names it and the sidebar tags it "outside the tree"; the next attempt (any `bot.created`, startup) tries again. +- **Leads.** A suitable existing lead is a member already marked as that group's lead, already coordinating that group (it has children and the key, project and domain only), or named for it ("Life", "FlyBot Lead") or as a lead ("Kitchen Manager", 负责人). It gets `lead { key, label }`. Only when none fits a new lead is created with a clear role (`