diff --git a/changelog.d/447-tree-allocation.added.md b/changelog.d/447-tree-allocation.added.md new file mode 100644 index 00000000..9c12f463 --- /dev/null +++ b/changelog.d/447-tree-allocation.added.md @@ -0,0 +1 @@ +Chief now places new Bots sensibly up front instead of piling them flat under itself. Before creating a Bot it can search the whole tree with `find_bots` and reuses a Bot that already has the same role, at any depth, instead of making a duplicate. `create_team` sets up a project as one lead with its roles under it (for example Chief → FlyBot Lead → Dev, Test, Maintenance, Repo), which takes one slot under Chief, and `create_child_bot` can put a Bot under one of the caller's own descendants; a later Bot of a group goes under that group's lead. A Bot whose parent is gone is moved back into the tree by role. A full level, orphans and duplicates stay the job of the tree's automatic reorganization ([#447](https://github.com/ThinkFlowLab/FlyBot/issues/447)). diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 4b2eac54..2a07f80d 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/organize.js` | Self-organization (#446): placement by role, role groups and leads, repair, daily tidy (merge, idle, archive), journal and undo. | +| `plugins/tree/src/organize.js` | Self-organization (#446): placement by role, role groups and leads, repair (over-full parents, orphans), daily tidy (merge, idle, archive), journal and undo. Exposes the shared primitives (`shared`) allocation uses. | +| `plugins/tree/src/allocate.js` | Forward allocation (#447): `find_bots`, reuse before create, `create_child_bot(parent?)` placement, `create_team` (lead plus children), on `organize.js`'s shared primitives. | | `plugins/tree/src/roles.js` | Role profiles and similarity (name, role label, description, own prompt, recent tools; deterministic, `classify` hook). | ## Entry points @@ -38,14 +39,25 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). ### Child creation (#16, Option B) -- 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 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). +- Every tree member (not only the Main/root) may call `create_child_bot`. The new Bot goes under the caller or, with `parent` (#447), under one of the caller's descendants; never elsewhere (a Bot manages only its own downstream). +- `maxChildren` and depth limits stay, checked at the target parent. Parallel creates are serialized per (target) 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 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. +### Placement rules: forward allocation (#447, owner decisions 2026-10-19 ~19:45 and 21:27) + +Allocation puts a new Bot in a sensible place once; the self-organization below (#446) is the safety net that keeps the tree healthy afterwards. `src/allocate.js` uses `organize.js`'s shared primitives (`organizer.shared`: role profiles, homes, `fits`, `createLead`, `markLead`, `move`, journal, the reorganization queue), so explicit allocation and automatic reorganization have one set of limit checks and one prompt / trace refresh. + +- **Reuse before create.** `find_bots(query?)` (read-only, every tree Bot) lists the Bots in the caller's branch (for Chief: the whole tree) with `id`, `name`, `role`, `parent`, `depth`, `path`, `children`, `lead` and `via` (the caller's direct child on the path, whom it delegates to). With a query, only matches, best first: same role (`sameRole`), the query in the name or role, shared role words (stems: "test" finds "Tester"), the same project, the same domain. Empty query: an overview (first 100). `create_child_bot` checks the whole branch first: a Bot with the same role at any depth is returned as `{ reused, note }` and nothing is created. A Bot outside the caller's branch is not looked at (the daily tidy merges duplicates across branches). +- **Placement-aware creation.** `create_child_bot(name, role_description, parent?)`: `parent` (id or unique name) must be the caller or a descendant, else `parent must be you or one of your descendants`. Limits, depth and the sibling-name check apply at the target, under that parent's lock. Without `parent`, a **marked group lead** in the caller's branch whose group the new Bot belongs to takes it (most specific group first, then the shallower lead, when it has room): "FlyBot Docs" goes under "FlyBot Lead". A Bot that could lead that group itself, or a namesake, never goes under it; with no such lead the caller is the parent. The result names the parent and the tree path. +- **Teams: lead plus children.** `create_team(team, members, parent?)` with `members` as `"Name: role"` strings (2 to `maxChildren`): `" Lead"` (role "Lead of the group: coordinates …", `lead { key, label }` from `roles.js`) under the caller or `parent`, and the members under it: `Chief → FlyBot Lead → {Dev, Test, Maintenance, Repo}`, one slot under Chief. An existing lead of that group in the caller's branch (marked for it, named "" / " Lead", or coordinating it) is reused and marked; a member with the same role already in the branch is reused, and moved under the lead when it sits flat next to it (that frees its slot, so a team fits at a full level when it reuses a flat member). Members that do not fit are listed as not created. A team that moves or marks existing Bots is a journaled run (trigger `team`, one-click undo); a fresh team is a plain create. +- **Plan card**: not built. The codebase has no chat card for a model-proposed plan (approval cards belong to social's message holds), so forward allocation proceeds and names the resulting paths; deferral recorded on #447. Automatic reorganization never asks first. +- **Prompts.** Chief (and every coordinator) is told: `find_bots` before creating and reuse what fits; a project with several roles is one lead with children (`create_team`); a new Bot of a group goes under its lead; at `maxChildren - 2` children or more, group related children under a lead instead of adding another flat one; name the tree path of every new Bot; a full level, orphans and duplicates are fixed by the automatic reorganization, without asking the owner first. +- **Over capacity and orphans → #446.** A full target refuses with `limit reached` (pass a fitting `parent`, or let the tree regroup the level by role). A Bot made outside `create_child_bot` is placed by role (below). An **orphan** (its `parent` no longer in the tree) is a reparent candidate, never parked outside for good: each repair pass (startup, every `bot.created`, after a deletion) moves it with its subtree to the home its role points to (its group's lead, else the root) where it fits; a journaled, undoable move. One that fits nowhere yet is retried on the next pass. + ### Self-organization (#446, owner decision 2026-10-19 21:27) -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. +The tree organizes itself by role instead of leaving Bots outside it or a parent over the limit. Forward allocation (above, #447) is built on these primitives. - **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. @@ -69,7 +81,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. The `#446` tests in `test/tree.test.js` cover role grouping (Life Lead, FlyBot Lead), existing leads, no unrelated fallback, the cap at every step, the daily tidy (merge, idle, archive), journal, undo and the client notice; `test/host.test.js` runs `create_bot` and the `tree/reorg` / `tree/undo` endpoints on the real host. `#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. The `#447` tests cover reuse of a same-role Bot at depth 3 and `find_bots` (scope, fields, ranking), `create_child_bot(parent)` refusing a non-descendant (sibling, ancestor, outsider, unknown) and checking limits at the target under its lock, `create_team` (Chief → FlyBot Lead → four members, one slot; reuse of the lead and members; a flat member's slot at a full level, journal and undo), later Bots joining their group's lead (only down leads of groups they belong to), and orphan reattachment; `test/host.test.js` runs `find_bots`, `create_team` and `create_child_bot(parent)` on the real host. The `#446` tests in `test/tree.test.js` cover role grouping (Life Lead, FlyBot Lead), existing leads, no unrelated fallback, the cap at every step, the daily tidy (merge, idle, archive), journal, undo and the client notice; `test/host.test.js` runs `create_bot` and the `tree/reorg` / `tree/undo` endpoints on the real host. `#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. ## Common mistakes diff --git a/plugins/tree/README.md b/plugins/tree/README.md index 7cbfce28..11c472db 100644 --- a/plugins/tree/README.md +++ b/plugins/tree/README.md @@ -14,7 +14,7 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness). - **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 regrouped by role automatically (#446), the same way as a full level, until it is within the limit. Each move is journaled and 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). +- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)` and `create_team(team, members, parent?)`. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. 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, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). 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 diff --git a/plugins/tree/src/allocate.js b/plugins/tree/src/allocate.js new file mode 100644 index 00000000..d7846a9f --- /dev/null +++ b/plugins/tree/src/allocate.js @@ -0,0 +1,255 @@ +// Forward allocation (#447, owner decisions 2026-10-19 ~19:45 and 21:27 Asia/Shanghai): where a NEW +// Bot goes when a tree Bot asks for one. It puts each Bot in a sensible place once; the +// self-organization (src/organize.js, #446) stays the safety net for a level that is full, orphans +// and duplicates. Everything here uses organize.js's shared primitives (role profiles, homes, lead +// creation, moves, journal) and the same limits; there is no second implementation. +// +// - Reuse before create: a Bot with the same role anywhere in the caller's branch (for Chief, the +// whole tree) is reused instead of a duplicate being made. +// - Placement: `parent` must be the caller or one of its descendants (a Bot manages only its own +// downstream); limits are checked at that parent, under its per-parent lock. With no `parent`, a +// marked group lead in the caller's branch whose group the new Bot belongs to takes it ("FlyBot +// Docs" goes under "FlyBot Lead"), else the caller. +// - Teams: a project or group with several roles becomes a lead plus children, one slot under the +// parent (Chief → FlyBot Lead → {Dev, Test, …}); an existing lead of that group is reused. +import { profileOf, keysOf, leadNameOf, canLead, sameRole, matchOf, norm } from './roles.js' +import { checkCreate, MAX_NAME_LENGTH } from './limits.js' + +export const LEAD_SUFFIX = ' Lead' +export const MAX_TEAM_LENGTH = MAX_NAME_LENGTH - LEAD_SUFFIX.length +const MAX_FOUND = 30 +const MAX_OVERVIEW = 100 + +export function createAllocator({ fb, limits, snapshot, childrenOf, depthOf, traceOf, shared, createUnder, withParentLock, refreshAll, classify }) { + const { profile, subtreeOf, heightOf, fits, homesFor, createLead, markLead, move, begin, commit, exclusive } = shared + const get = (id, key) => fb.getField(id, key) + const profileFor = (name, role) => profileOf({ name, treeRole: role }, classify) + + function member(nodes, callerId) { + if (!nodes[callerId]) throw new Error('this bot is not part of the tree') + } + /** The caller itself, or a descendant named by id or (unique) name. Anything else is refused. */ + function resolveParent(nodes, callerId, ref) { + const r = typeof ref === 'string' ? ref.trim() : '' + if (!r) return callerId + const scope = subtreeOf(nodes, callerId) + if (scope.includes(r)) return r + const byName = scope.filter(id => norm(nodes[id].name) === norm(r)) + if (byName.length === 1) return byName[0] + if (byName.length > 1) throw new Error(`more than one Bot in your branch is named "${r}"; pass the id of the one you mean (${byName.join(', ')})`) + throw new Error(`parent must be you or one of your descendants; "${r}" is not in your branch (find_bots lists your branch)`) + } + // The caller's direct child on the way down to `id` (whom the caller delegates to); undefined for itself. + function viaOf(nodes, callerId, id) { + for (let at = id, seen = new Set(); at && nodes[at] && !seen.has(at); at = nodes[at].parent) { + seen.add(at) + if (nodes[at].parent === callerId) return nodes[at] + } + return undefined + } + function describe(nodes, callerId, id) { + const n = nodes[id] + const via = viaOf(nodes, callerId, id) + const lead = get(id, 'lead') + return { + id, name: n.name, role: n.role, depth: depthOf(nodes, id), path: traceOf(nodes, id), + parent: nodes[n.parent]?.name ?? null, children: childrenOf(nodes, id).length, + ...(lead?.label ? { lead: lead.label } : {}), ...(via && via.id !== id ? { via: via.name } : {}), + } + } + /** A Bot in the caller's branch (shallowest first) with the same role as the one asked for. */ + function sameRoleIn(nodes, callerId, name, role, except = new Set()) { + const want = profileFor(name, role) + for (const id of subtreeOf(nodes, callerId)) { + if (id === callerId || except.has(id) || nodes[id].root) continue + if (sameRole(want, profile(nodes, id))) return id + } + return undefined + } + const reuseNote = (d, name) => `A Bot with the role of "${name}" already exists: ${d.path}. Reuse it${d.via ? ` (delegate through ${d.via})` : ''} instead of creating a duplicate; nothing was created.` + + /** + * find_bots: Bots in the caller's branch (Chief: the whole tree) with name, role, parent, depth + * and path. With a query, only matches, best first (same role, name, role words, project, domain). + */ + function findBots(callerId, query) { + const nodes = snapshot() + member(nodes, callerId) + const ids = subtreeOf(nodes, callerId).filter(id => id !== callerId) + const text = String(query ?? '').trim() + const scope = nodes[callerId].root ? 'the whole tree' : `the branch under ${nodes[callerId].name}` + if (!text) { + const bots = ids.slice(0, MAX_OVERVIEW).map(id => describe(nodes, callerId, id)) + return { scope, bots, total: ids.length, ...(ids.length > MAX_OVERVIEW ? { truncated: true } : {}), limits } + } + const q = profileFor(text, '') + const found = ids.map(id => ({ id, m: matchOf(q, profile(nodes, id), text) })).filter(x => x.m.score > 0) + .sort((a, b) => (b.m.score - a.m.score) || (depthOf(nodes, a.id) - depthOf(nodes, b.id))) + return { scope, query: text, bots: found.slice(0, MAX_FOUND).map(x => ({ ...describe(nodes, callerId, x.id), match: x.m.why })), total: found.length, limits } + } + + /** Where create_child_bot puts a new Bot: { reused, note } or { parentId }. */ + function planChild(callerId, name, role, parentRef) { + const nodes = snapshot() + member(nodes, callerId) + const parentId = resolveParent(nodes, callerId, parentRef) + const clean = String(name ?? '').trim() + const match = sameRoleIn(nodes, callerId, clean, role) + if (match) { const d = describe(nodes, callerId, match); return { reused: d, note: reuseNote(d, clean) } } + if (parentId !== callerId || (typeof parentRef === 'string' && parentRef.trim())) return { parentId } + const x = { name: clean, height: 1, p: profileFor(clean, role) } + const ok = at => !checkCreate({ parentName: nodes[at].name, parentDepth: depthOf(nodes, at), siblings: childrenOf(nodes, at), name: clean, limits }) + return { parentId: homesFor(nodes, callerId, x, { leadsOnly: true }).find(ok) ?? callerId } + } + + /** + * create_child_bot(name, role_description, parent?): reuse, or create under the planned parent + * (its per-parent lock; limits checked there). Resolves { created } or { reused, note }. + */ + async function createChild(callerId, name, role, parentRef) { + for (let attempt = 0; ; attempt++) { + const plan = planChild(callerId, name, role, parentRef) + if (plan.reused) return plan + const out = await withParentLock(plan.parentId, async () => { + const again = planChild(callerId, name, role, parentRef) + if (again.reused) return again + // A parallel create filled the chosen lead: plan again outside this lock. + if (again.parentId !== plan.parentId && attempt < 3) return { retry: true } + const created = await createUnder(again.parentId, name, role) + const nodes = snapshot() + return { created: { ...created, parent: { id: again.parentId, name: nodes[again.parentId]?.name }, path: traceOf(nodes, created.id) } } + }) + if (!out.retry) return out + } + } + + // ------------------------------------------------------------ teams + const teamKey = label => keysOf(profileFor(label, ''))[0] ?? `project:${norm(label)}` + /** An existing lead for the group in the caller's branch: marked for it, named for it, or coordinating it. Shallowest first. */ + function leadIn(nodes, callerId, key, label) { + const ids = subtreeOf(nodes, callerId).filter(id => !nodes[id].root) + const marked = ids.find(id => get(id, 'lead')?.key === key) + if (marked) return marked + const named = ids.find(id => [norm(label), norm(leadNameOf(label))].includes(norm(nodes[id].name))) + if (named) return named + return ids.find(id => canLead(profile(nodes, id), key, { lead: get(id, 'lead'), hasChildren: childrenOf(nodes, id).length > 0, names: [label] })) + } + /** A member is "Name: role" (the tool's form) or { name, role_description }. */ + const memberOf = m => { + if (typeof m === 'string') { const i = m.indexOf(':'); return i > 0 ? { name: m.slice(0, i).trim(), role_description: m.slice(i + 1).trim() } : { name: m.trim(), role_description: '' } } + return { name: typeof m?.name === 'string' ? m.name : '', role_description: typeof m?.role_description === 'string' ? m.role_description : '' } + } + function validateMembers(list, label) { + if (!Array.isArray(list)) return 'members must be a list of "Name: role" entries' + if (list.length < 2) return 'a team needs at least 2 members; for one Bot use create_child_bot' + if (list.length > limits.maxChildren) return `a team has at most ${limits.maxChildren} members (maxChildren=${limits.maxChildren})` + const seen = new Set() + for (const m of list.map(memberOf)) { + const name = m.name.trim(), role = m.role_description.trim() + if (!name || !role) return `every member needs a name and a role, as "Name: role" (got "${name || '?'}")` + if (name.length > MAX_NAME_LENGTH) return `member name must be at most ${MAX_NAME_LENGTH} characters: "${name}"` + if (role.length > 1000) return `role_description must be at most 1000 characters (${name})` + if (seen.has(norm(name))) return `two members are named "${name}"` + if (norm(name) === norm(leadNameOf(label))) return `"${name}" is the lead's own name; list only the members` + seen.add(norm(name)) + } + return null + } + function planTeam(callerId, label, list, parentRef) { + const nodes = snapshot() + member(nodes, callerId) + const parentId = resolveParent(nodes, callerId, parentRef) + const key = teamKey(label) + const leadId = leadIn(nodes, callerId, key, label) + return { nodes, parentId, key, leadId } + } + + /** + * create_team(team, members, parent?): " Lead" plus its members, one slot under the parent. + * An existing lead of the group in the caller's branch is reused (and marked); members that + * already exist with the same role are reused, and moved under the lead when they sit flat next + * to it. Moves of existing Bots are journaled (one-click undo in the tree view). + */ + async function createTeam(callerId, { team, members, parent }) { + const label = String(team ?? '').trim() + if (!label) throw new Error('team is required') + if (label.length > MAX_TEAM_LENGTH) throw new Error(`team must be at most ${MAX_TEAM_LENGTH} characters (the lead is named " Lead")`) + const bad = validateMembers(members, label) + if (bad) throw new Error(bad) + const list = members.map(memberOf).map(m => ({ name: m.name.trim(), role: m.role_description.trim() })) + for (let attempt = 0; ; attempt++) { + const first = planTeam(callerId, label, list, parent) + const lockKey = first.leadId ?? first.parentId + const out = await withParentLock(lockKey, () => exclusive(async () => { + const plan = planTeam(callerId, label, list, parent) + if ((plan.leadId ?? plan.parentId) !== lockKey && attempt < 3) return { retry: true } + return buildTeam(callerId, label, list, plan) + })) + if (!out.retry) return out + } + } + async function buildTeam(callerId, label, list, { parentId, key, leadId: existingLead }) { + let nodes = snapshot() + const run = begin('team') + const done = list.map(m => ({ name: m.name, role: m.role })) + // Reuse: the same role already in the caller's branch (never the lead itself). + const taken = new Set(existingLead ? [existingLead] : []) + for (const d of done) { + const id = sameRoleIn(nodes, callerId, d.name, d.role, taken) + if (id) { d.reusedId = id; taken.add(id) } + } + let leadId = existingLead + const home = leadId ? nodes[leadId].parent : parentId + // A reused member flat next to the lead (or at the parent, for a new lead) joins the lead. + const flat = done.filter(d => d.reusedId && nodes[d.reusedId].parent === home && home !== null) + if (leadId) markLead(run, leadId, key, label) + else { + const depth = depthOf(nodes, parentId) + const movers = flat.filter(d => depth + 1 + heightOf(nodes, d.reusedId) <= limits.maxDepth).map(d => ({ id: d.reusedId, name: nodes[d.reusedId].name })) + const leadName = leadNameOf(label) + if (depth + 2 > limits.maxDepth) throw new Error(`limit reached: a team under ${nodes[parentId].name} would put its members at depth ${depth + 2} (maxDepth=${limits.maxDepth})`) + if (!movers.length) { + const reason = checkCreate({ parentName: nodes[parentId].name, parentDepth: depth, siblings: childrenOf(nodes, parentId), name: leadName, limits }) + if (reason) throw new Error(`${reason}. Pass a parent in your branch with room (find_bots), or the tree regroups this level by role on its own`) + } + leadId = await createLead(run, parentId, { key, label, movers, memberNames: done.map(d => d.name) }) + if (!leadId) throw new Error('the parent left the tree while the team was being set up') + for (const m of movers) done.find(d => d.reusedId === m.id).moved = true + } + nodes = snapshot() + if (existingLead) { + for (const d of flat) { + if (d.reusedId === leadId || !fits(nodes, leadId, { id: d.reusedId, name: nodes[d.reusedId].name, height: heightOf(nodes, d.reusedId) })) continue + move(run, d.reusedId, leadId); d.moved = true + nodes = snapshot() + } + } + for (const d of done) { + if (d.reusedId) continue + try { + const c = await createUnder(leadId, d.name, d.role) + d.createdId = c.id + run.actions.push({ op: 'place', bot: c.id, name: d.name, to: leadId, toName: snapshot()[leadId]?.name }) + } catch (error) { d.error = error instanceof Error ? error.message : String(error) } + } + // Only a run that changed existing Bots is a reorganization (journaled, undoable); a fresh team is a plain create. + if (run.actions.some(a => a.op === 'move' || a.op === 'mark-lead')) commit(run) + refreshAll() + nodes = snapshot() + const lead = { id: leadId, name: nodes[leadId].name, path: traceOf(nodes, leadId), ...(existingLead ? { reused: true } : { created: true }) } + const out = done.map(d => { + const id = d.createdId ?? d.reusedId + if (!id) return { name: d.name, error: d.error } + return { id, name: nodes[id]?.name ?? d.name, path: traceOf(nodes, id), ...(d.createdId ? { created: true } : { reused: true }), ...(d.moved ? { moved: true } : {}) } + }) + const names = out.filter(m => !m.error && nodes[m.id]?.parent === leadId).map(m => m.name) + const notes = [`Team: ${lead.path} → {${names.join(', ')}}. Delegate ${label} work to ${lead.name}; it hands each part to the member that fits.`] + const elsewhere = out.filter(m => m.reused && !m.error && nodes[m.id]?.parent !== leadId) + if (elsewhere.length) notes.push(`Reused where they are: ${elsewhere.map(m => m.path).join('; ')}.`) + const failed = out.filter(m => m.error) + if (failed.length) notes.push(`Not created: ${failed.map(m => `${m.name} (${m.error})`).join('; ')}.`) + return { team: { lead, members: out }, note: notes.join(' ') } + } + + return { findBots, planChild, createChild, createTeam, resolveParent } +} diff --git a/plugins/tree/src/organize.js b/plugins/tree/src/organize.js index 9bc5ee33..3bc8be89 100644 --- a/plugins/tree/src/organize.js +++ b/plugins/tree/src/organize.js @@ -153,7 +153,7 @@ export function createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, war * over maxChildren, not even for a moment. */ async function createLead(run, parentId, plan) { - const memberNames = plan.movers.map(m => m.name) + const memberNames = plan.memberNames ?? plan.movers.map(m => m.name) let nodes = snapshot() let name = leadNameOf(plan.label) for (let i = 2; hostsName(nodes, parentId, name) || memberNames.some(m => norm(m) === norm(name)); i++) name = `${leadNameOf(plan.label)} ${i}` @@ -222,15 +222,17 @@ export function createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, war return plans.find(p => mode === 'daily' || p.freed >= 1 || p.takesIncoming) } + /** Marks an existing member as the lead of group `key` (journaled once). */ + function markLead(run, leadId, key, label) { + if (get(leadId, 'lead')) return + set(leadId, 'lead', { key, label }) + run.actions.push({ op: 'mark-lead', bot: leadId, name: snapshot()[leadId]?.name ?? leadId, key, label }) + } /** Runs a plan; resolves the lead id. Sync (returns the id) when the lead exists, else a Promise. */ function runPlan(run, parentId, plan) { if (!plan.lead) return createLead(run, parentId, plan) const leadId = plan.lead - const nodes = snapshot() - if (!get(leadId, 'lead')) { - set(leadId, 'lead', { key: plan.key, label: plan.label }) - run.actions.push({ op: 'mark-lead', bot: leadId, name: nodes[leadId]?.name ?? leadId, key: plan.key, label: plan.label }) - } + markLead(run, leadId, plan.key, plan.label) for (const m of plan.movers) { const now2 = snapshot() if (!m.incoming && now2[m.id]?.parent === parentId && fits(now2, leadId, { id: m.id, name: m.name, height: heightOf(now2, m.id) })) move(run, m.id, leadId) @@ -239,20 +241,32 @@ export function createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, war } // ------------------------------------------------------------ placement + /** The lead groups strictly between `root` and `id` (exclusive of both). */ + const groupsAbove = (nodes, root, id) => { + const out = [] + for (let at = nodes[id]?.parent, seen = new Set(); at && at !== root && nodes[at] && !seen.has(at); at = nodes[at].parent) { seen.add(at); const k = get(at, 'lead')?.key; if (k) out.push(k) } + return out + } /** * Where a new Bot may go, best first: its namesake, then every tree Bot that leads a group it * belongs to (most specific group first, then the shallower lead), then the root. A broad area is * joined only through a lead made for it ("Work Lead"); a shared word never makes a lead. */ - function homesFor(nodes, root, x) { + function homesFor(nodes, root, x, { leadsOnly = false } = {}) { const keys = keysOf(x.p) const found = [] for (const id of subtreeOf(nodes, root)) { if (id === root) continue - if (norm(nodes[id].name) === norm(x.name)) { found.push({ id, rank: -1, depth: depthOf(nodes, id) }); continue } // the daily tidy merges them + if (norm(nodes[id].name) === norm(x.name)) { if (!leadsOnly) found.push({ id, rank: -1, depth: depthOf(nodes, id) }); continue } // the daily tidy merges them const lead = get(id, 'lead'), hasChildren = childrenOf(nodes, id).length > 0 const ignored = ignoredAt(nodes, nodes[id].parent) let rank = Infinity + // #447 forward placement: only a marked group lead takes a new Bot, never one that could lead that + // group itself, and only down a path of groups it belongs to (no dev Bot inside "FlyBot Lead"'s Dev Lead unless it is FlyBot's). + if (leadsOnly) { + if (lead?.key && keys.includes(lead.key) && !canLead(x.p, lead.key, { names: [x.name] }) && groupsAbove(nodes, root, id).every(k => keys.includes(k))) found.push({ id, rank: KIND_RANK[kindOf(lead.key)] ?? 9, depth: depthOf(nodes, id) }) + continue + } for (const k of keys) { if (ignored.has(k) && lead?.key !== k) continue if (kindOf(k) === 'word' || (kindOf(k) === 'area' && lead?.key !== k)) continue @@ -356,6 +370,7 @@ export function createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, war */ function repair() { const overFull = nodes => Object.keys(nodes).find(id => childrenOf(nodes, id).length > limits.maxChildren) + if (pending === 0) reattachOrphans() if (pending === 0) { for (let guard = 0; guard < 64; guard++) { const nodes = snapshot() @@ -374,6 +389,32 @@ export function createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, war return enqueue(async () => { await repairInline(); refreshAll() }) } + /** + * Orphans (#447): a member whose parent is no longer in the tree (deleted while the plugin was off, + * archived, or never there) is never left outside for good. It moves, with its subtree, to the + * home its role points to (its group's lead, else the root) where it fits; a journaled move. + * One that fits nowhere yet is tried again on the next pass and keeps its subtree. + */ + function reattachOrphans() { + const root = rootId() + let nodes = snapshot() + if (!root || !nodes[root]) return + const orphans = Object.values(nodes).filter(n => !n.root && n.id !== root && n.parent !== null && !nodes[n.parent]) + if (!orphans.length) return + const run = begin('repair') + for (const o of orphans) { + nodes = snapshot() + const x = { id: o.id, name: o.name, height: heightOf(nodes, o.id), p: profile(nodes, o.id) } + const home = homesFor(nodes, root, x).find(at => !subtreeOf(nodes, o.id).includes(at) && fits(nodes, at, x)) + if (!home) { warn(`flybot-tree: ${o.name} lost its parent and no level that fits its role has room yet; tried again on the next pass`); continue } + const from = o.parent + set(o.id, 'parent', home) + run.actions.push({ op: 'move', bot: o.id, name: o.name, from, fromName: fb.getBot(from)?.name ?? 'a removed Bot', to: home, toName: nodes[home].name }) + } + commit(run) + if (run.actions.length) refreshAll() + } + // ------------------------------------------------------------ daily tidy const isGranted = id => fb.hasAnyGrant?.(id) === true const lastActive = id => get(id, 'activity')?.at ?? get(id, 'seenAt') @@ -573,5 +614,13 @@ export function createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, war const idle = () => pending === 0 ? Promise.resolve() : queue.then(() => idle()) const busy = () => pending > 0 - return { busy, place, repair, tidy, maybeTidy, undo, dismiss, overview, noteActivity, idle, planLevel, describeAction, repairArchiveNames } + /** + * Runs `job` in the reorganization queue (one at a time with placement, repair and tidy; a lead + * being set up there is not re-placed by adoption). Resolves the job's value, rejects with its error. + */ + const exclusive = job => new Promise((resolve, reject) => { enqueue(async () => { try { resolve(await job()) } catch (error) { reject(error) } }) }) + // The shared primitives explicit allocation (#447, src/allocate.js) is built on: the same role + // profiles, homes, limit checks, lead creation, moves and journal as the self-organization. + const shared = { profile, subtreeOf, heightOf, fits, homesFor, createLead, markLead, move, begin, commit, exclusive } + return { busy, place, repair, tidy, maybeTidy, undo, dismiss, overview, noteActivity, idle, planLevel, describeAction, repairArchiveNames, shared } } diff --git a/plugins/tree/src/prompt.js b/plugins/tree/src/prompt.js index 35275e4b..fa37af7a 100644 --- a/plugins/tree/src/prompt.js +++ b/plugins/tree/src/prompt.js @@ -10,29 +10,40 @@ export function traceRule(trace, root) { } // A granted tree Bot can be contacted from outside the tree (a friend's Bot, another owner's Bot). export const OUTSIDE_RULE = 'If someone has been granted access to you, a bot outside the tree (a friend\'s bot, or another owner\'s bot) may message you directly. Answer that sender directly, with no trace line, and delegate only to your own children, never to bots outside the tree.' -export function systemPrompt({ name, role, trace, children, root = false, notes = [] }) { +// Placement rules (#447): reuse first, a lead plus children for a project, group before a level fills. +// What still goes wrong (a full level, orphans, duplicates) is the automatic reorganization's job (#446). +export function placementRules(limits) { + const near = limits?.maxChildren ? `${Math.max(2, limits.maxChildren - 2)} or more of your ${limits.maxChildren}` : 'close to maxChildren' + return [ + 'Placement: before creating, call find_bots to look for a Bot whose role fits anywhere in your branch, and reuse it (delegate through the child on its path) instead of creating a duplicate.', + 'A project or group with several roles gets one lead with the roles as its children: create_team (e.g. you → FlyBot Lead → Dev, Test, Maintenance), one slot under you, not several flat children. A new Bot for an existing group goes under that group\'s lead.', + `When you have ${near} children, group related ones under a lead instead of adding another flat child. Name the tree path of every new Bot in your reply.`, + 'A full level, orphaned Bots and duplicates are fixed by the tree\'s automatic reorganization (it regroups by role, merges duplicates and tells the owner afterwards); you do not ask the owner first.', + ] +} +export function systemPrompt({ name, role, trace, children, root = false, notes = [], limits }) { const head = root ? [`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.' + const tools = 'Tree tools: list_children() shows your current direct children (it may be newer than this prompt). find_bots(query) searches your whole branch (name, role, parent, depth, path). create_child_bot(name, role_description, parent?) creates one Bot under you or under one of your descendants. create_team(team, members, parent?) creates " Lead" with the members under it.' // #446: create_bot is a Main Bot tool; the tree places what it makes by role, under 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 its role, and when a level is full the tree regroups related bots under a lead (e.g. "FlyBot Lead") by itself.' 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 ? [limitRule] : [OUTSIDE_RULE]), tools, + 'You may message only your parent and your direct children.', ...(root ? [limitRule, ...placementRules(limits)] : [OUTSIDE_RULE]), tools, traceRule(trace, root), ...notes].join('\n') } return [...head, '', - 'You are a COORDINATOR. Your direct children (the only bots you may delegate to; you cannot see deeper levels):', + 'You are a COORDINATOR. Your direct children (the only bots you may delegate to; find_bots shows 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, delegate the part to the closest child; it can create a child under itself.', + '2. Create a Bot ONLY when no existing Bot in your branch fits (check find_bots first). Never create duplicates or overlapping scopes. If creation is rejected by limits, create it under the descendant whose scope fits (parent), or delegate the part to the closest child.', '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 ? [limitRule] : [OUTSIDE_RULE]), tools, ...notes].join('\n') + 'You may message only your parent and your direct children.', ...(root ? [limitRule] : [OUTSIDE_RULE]), ...placementRules(limits), tools, ...notes].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/roles.js b/plugins/tree/src/roles.js index 192fb65f..232c354c 100644 --- a/plugins/tree/src/roles.js +++ b/plugins/tree/src/roles.js @@ -29,7 +29,7 @@ const GENERIC = new Set(`bot bots agent agents team the and for of to in on with const LEAD_WORDS = new Set(['lead', 'leader', 'manager', 'coordinator', 'head', 'director', 'captain', 'boss', 'supervisor']) const LEAD_CJK = ['负责人', '主管', '组长', '经理', '总管', '统筹'] // Tools every Bot uses: they say nothing about its role. -const COMMON_TOOLS = new Set(['message_bot', 'list_children', 'create_child_bot', 'create_bot', 'list_bots', 'stop_bot', 'reply', 'think', 'todo', 'memory', 'read', 'write']) +const COMMON_TOOLS = new Set(['message_bot', 'list_children', 'create_child_bot', 'find_bots', 'create_team', 'create_bot', 'list_bots', 'stop_bot', 'reply', 'think', 'todo', 'memory', 'read', 'write']) const CJK = /[\u3400-\u9fff]/ export const norm = s => String(s ?? '').trim().toLowerCase() @@ -125,3 +125,24 @@ export function sameRole(a, b) { if (a.domain && a.domain === b.domain && wordsOf(a.roleText).length >= 4 && jaccard(a.roleText, b.roleText) >= 0.8) return true return false } + +// Two role words that are the same or one a stem of the other ("test" / "tester" / "tests"). +const sameStem = (a, b) => a === b || (Math.min(a.length, b.length) >= 4 && (a.startsWith(b) || b.startsWith(a))) +/** + * How well a Bot (profile `p`) matches a search (profile `q` of the query text, #447 find_bots): + * { score, why }, score 0 = no match. The same role scores highest, then the query inside the name, + * shared role words, the same project, and last the same domain. + */ +export function matchOf(q, p, text = '') { + if (q.words.length && sameRole(q, p)) return { score: 100, why: 'same role' } + let score = 0 + const why = [] + const t = norm(text) + if (t && p.lowerName.includes(t)) { score += 60; why.push('name') } else if (t.length >= 3 && p.roleText.includes(t)) { score += 30; why.push('role') } + const shared = q.terms.filter(a => p.terms.some(b => sameStem(a, b))) + if (shared.length) { score += 20 * shared.length; why.push(`role words: ${shared.join(', ')}`) } + const project = q.projects.find(w => p.projects.includes(w)) + if (project) { score += 15; why.push(`project ${project}`) } + if (q.domain && q.domain === p.domain) { score += 5; why.push(`domain ${BY_ID.get(q.domain).label}`) } + return { score, why: why.join('; ') } +} diff --git a/plugins/tree/src/tools.js b/plugins/tree/src/tools.js index 9a2f8cca..74b0cf1a 100644 --- a/plugins/tree/src/tools.js +++ b/plugins/tree/src/tools.js @@ -1,7 +1,9 @@ import { MAX_NAME_LENGTH } from './limits.js' // = ds-bot roster's 40-character name truncation -// Model tools. The caller comes from the tool context ({ botId }), never from arguments, -// so a bot can only create children under itself. Visible to every tree member (#16 Option B: -// not Main/root-only). maxChildren and depth stay in createChild; a new child is not granted. +import { MAX_TEAM_LENGTH } from './allocate.js' +// Model tools. The caller comes from the tool context ({ botId }), never from arguments. A Bot +// creates only in its own downstream: `parent` must be the caller or one of its descendants (#447). +// Visible to every tree member (#16 Option B: not Main/root-only). maxChildren and depth are +// checked at the target parent; a new child is not granted. find_bots is read-only. // tools.register keeps only type/description/enum/items from JSON Schema (minLength/maxLength // are dropped), so every execute() validates its own arguments with validateArgs(). @@ -18,6 +20,7 @@ export function validateArgs(schema, args) { if (Number.isInteger(s.minLength) && n < s.minLength) return `${key} must be at least ${s.minLength} characters` if (Number.isInteger(s.maxLength) && n > s.maxLength) return `${key} must be at most ${s.maxLength} characters` } + if (s.type === 'array' && !Array.isArray(v)) return `${key} must be a list` } if (schema.additionalProperties === false) { const extra = Object.keys(a).filter(k => !Object.hasOwn(schema.properties ?? {}, k)) @@ -32,10 +35,29 @@ export const CREATE_CHILD_SCHEMA = Object.freeze({ properties: { name: { type: 'string', minLength: 1, maxLength: MAX_NAME_LENGTH, description: 'Short domain name, e.g. "Cooking"' }, role_description: { type: 'string', minLength: 1, maxLength: 1000, description: 'What the child is responsible for' }, + parent: { type: 'string', maxLength: 100, description: 'Optional: id or name of the Bot to create it under: you (default) or one of your descendants' }, }, required: ['name', 'role_description'], additionalProperties: false, }) +export const FIND_BOTS_SCHEMA = Object.freeze({ + type: 'object', + properties: { + query: { type: 'string', maxLength: 200, description: 'Optional: a name or role to look for, e.g. "tester". Empty lists your whole branch.' }, + }, + additionalProperties: false, +}) +export const CREATE_TEAM_SCHEMA = Object.freeze({ + type: 'object', + properties: { + team: { type: 'string', minLength: 1, maxLength: MAX_TEAM_LENGTH, description: 'Project or group name, e.g. "FlyBot". Its lead is named " Lead".' }, + // The host keeps only type/description/enum/items, and DSH needs an object item to declare its keys, so members are "Name: role" strings. + members: { type: 'array', items: { type: 'string' }, description: 'The roles, at least 2, each "Name: what it is responsible for", e.g. "Dev: writes and fixes the code"' }, + parent: { type: 'string', maxLength: 100, description: 'Optional: id or name of the Bot to put the lead under: you (default) or one of your descendants' }, + }, + required: ['team', 'members'], + additionalProperties: false, +}) export function registerTreeTools(fb, tree) { const inTree = botId => tree.isMember(botId) // visibleTo is sync @@ -54,15 +76,37 @@ export function registerTreeTools(fb, tree) { return { children: tree.childrenOf(nodes, botId).map(({ id, name, role }) => ({ id, name, role })), depth: tree.depthOf(nodes, botId), limits: tree.limits } }, }), + fb.registerTool({ + name: 'find_bots', + description: 'Read-only. Search the Bots in your branch (for the root: the whole tree) by name or role, with role, parent, depth and path. Use it before creating a Bot, to reuse one that already fits.', + parameters: FIND_BOTS_SCHEMA, + visibleTo: inTree, + async execute(args, { botId }) { + const bad = validateArgs(FIND_BOTS_SCHEMA, args ?? {}) + if (bad) return fail(bad) + try { return tree.findBots(botId, args?.query) } catch (e) { return fail(e) } + }, + }), fb.registerTool({ name: 'create_child_bot', - description: 'Create a NEW child bot directly under yourself, then delegate to it. Any tree bot may call this (not only the root). Only when none of your children fits; never a duplicate.', + description: 'Create ONE new Bot under yourself, or under one of your descendants with parent, then delegate to it. If a Bot with the same role already exists in your branch, it is reused and nothing is created. Without parent, a group lead in your branch that fits the new role (e.g. "FlyBot Lead") takes it. Any tree bot may call this (not only the root).', parameters: CREATE_CHILD_SCHEMA, visibleTo: inTree, async execute(args, { botId }) { const bad = validateArgs(CREATE_CHILD_SCHEMA, args) if (bad) return fail(bad) - try { return { created: await tree.createChild(botId, args.name, args.role_description) } } catch (e) { return fail(e) } + try { return await tree.allocateChild(botId, args.name, args.role_description, args.parent) } catch (e) { return fail(e) } + }, + }), + fb.registerTool({ + name: 'create_team', + description: 'Create a team for one project or group: " Lead" with its members as children, one slot under you (or under parent, one of your descendants). An existing lead of that group and existing Bots with the same roles in your branch are reused.', + parameters: CREATE_TEAM_SCHEMA, + visibleTo: inTree, + async execute(args, { botId }) { + const bad = validateArgs(CREATE_TEAM_SCHEMA, args) + if (bad) return fail(bad) + try { return await tree.createTeam(botId, { team: args.team, members: args.members, parent: args.parent }) } catch (e) { return fail(e) } }, }), ] diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index 0d34fb21..bc2ff27d 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -11,6 +11,7 @@ import { checkCreate } from './limits.js' import { systemPrompt, withTreeBlock, hasTreeBlock } from './prompt.js' import { forestOf } from './forest.js' import { createOrganizer } from './organize.js' +import { createAllocator } from './allocate.js' export { forestOf } export const TREE_KEYS = new Set(['parent', 'treeRole']) @@ -37,7 +38,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const promptFor = (id, nodes = snapshot()) => { const n = nodes[id] - return n && systemPrompt({ name: n.name, role: n.role, trace: traceOf(nodes, id), children: childrenOf(nodes, id), root: n.root, notes: notesFor(id, n) }) + return n && systemPrompt({ name: n.name, role: n.role, trace: traceOf(nodes, id), children: childrenOf(nodes, id), root: n.root, notes: notesFor(id, n), limits }) } const stamp = at => { const d = new Date(at); const p = v => String(v).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` } // The Main Bot hears what the last reorganization did (the owner sees it in the tree view); a kept @@ -48,7 +49,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const last = organizer.overview().runs[0] if (last && !last.undone && now() - last.at < 7 * 24 * 60 * 60 * 1000) { const lines = last.summary ?? [] - notes.push(`Last automatic tree reorganization (${stamp(last.at)}): ${lines.slice(0, 6).join('; ')}${lines.length > 6 ? `; and ${lines.length - 6} more` : ''}. The owner sees it in the tree view and can undo it there; tell them what moved if they ask about the team.`) + notes.push(`Last ${last.trigger === 'team' ? '' : 'automatic '}tree reorganization (${stamp(last.at)}): ${lines.slice(0, 6).join('; ')}${lines.length > 6 ? `; and ${lines.length - 6} more` : ''}. The owner sees it in the tree view and can undo it there; tell them what moved if they ask about the team.`) } } const merged = fb.getField(id, 'mergedFrom') @@ -66,6 +67,8 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const refreshAll = () => { const nodes = snapshot(); for (const id of Object.keys(nodes)) refreshPrompt(id, nodes) } const findRootId = () => { const nodes = snapshot(); return Object.values(nodes).find(n => n.root)?.id ?? Object.values(nodes).find(n => n.parent === null)?.id } const organizer = createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, warn, refreshAll: () => refreshAll(), writeBlock: (id, block) => writeBlock(id, block), findRootId, now, classify, tidy }) + // Forward allocation (#447) on the organizer's shared primitives: find_bots, reuse, parent?, teams. + const allocator = createAllocator({ fb, limits, snapshot, childrenOf, depthOf, traceOf, shared: organizer.shared, createUnder: (parentId, name, role) => createChildNow(parentId, name, role), withParentLock: (id, fn) => withParentLock(id, fn), refreshAll: () => refreshAll(), classify }) // ---------------------------------------------------------------- root /** The Main Bot (Chief): config.rootBotId if it exists, else the Main Bot named 'Chief', else the first Main Bot. */ @@ -157,7 +160,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis return rootTask } async function createRoot() { - const prompt = systemPrompt({ name: rootName, role: ROOT_LABEL, trace: rootName, children: [], root: true }) + const prompt = systemPrompt({ name: rootName, role: ROOT_LABEL, trace: rootName, children: [], root: true, limits }) return fb.createBot({ name: rootName, systemPrompt: withTreeBlock('', prompt), fields: { parent: null, treeRole: ROOT_ROLE } }) } @@ -173,7 +176,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis return run } - /** Creates a child under parentId (the CALLER; the tool never takes a parent argument). */ + /** Creates a child directly under parentId (limits checked there, per-parent lock). The tool goes through allocator.createChild (reuse, parent?). */ function createChild(parentId, name, roleDescription) { return withParentLock(parentId, () => createChildNow(parentId, name, roleDescription)) } @@ -236,5 +239,6 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const subscribe = fn => { listeners.add(fn); return () => listeners.delete(fn) } const { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, idle: settled, repairArchiveNames } = organizer - return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } + const { findBots, createChild: allocateChild, createTeam } = allocator + return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, allocateChild, createTeam, findBots, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } } diff --git a/plugins/tree/test/host.test.js b/plugins/tree/test/host.test.js index 364fe087..1e3a5a31 100644 --- a/plugins/tree/test/host.test.js +++ b/plugins/tree/test/host.test.js @@ -177,3 +177,27 @@ test('#446 on the real host: core create_bot cannot get past maxChildren; a full await h.stop() } }) + +test('#447 on the real host: find_bots, create_team (lead plus children) and create_child_bot(parent) register and work', async () => { + const h = await start() + try { + const fork = h.ctx.plugin(tree, {}) + const tf = h.fb.scope('flybot-tree').fields + await waitFor(() => tf.get('b', 'parent') === 'a') + const team = JSON.parse(await h.tool('create_team', { team: 'FlyBot', members: ['Dev: writes the FlyBot code', 'Test: runs the FlyBot tests'] }, 'a')) + assert.equal(team.error, undefined, JSON.stringify(team)) + const lead = team.team.lead.id + assert.equal(tf.get(lead, 'parent'), 'a') + assert.deepEqual(h.fb.bot.list().filter(b => tf.get(b.id, 'parent') === lead).map(b => b.name).sort(), ['Dev', 'Test']) + const docs = JSON.parse(await h.tool('create_child_bot', { name: 'Docs', role_description: 'writes docs', parent: 'FlyBot Lead' }, 'a')).created + assert.equal(tf.get(docs.id, 'parent'), lead) + assert.match(JSON.parse(await h.tool('create_child_bot', { name: 'X', role_description: 'x', parent: lead }, 'b')).error, /parent must be you or one of your descendants/) + const found = JSON.parse(await h.tool('find_bots', { query: 'test' }, 'a')) + assert.deepEqual(found.bots.map(b => [b.name, b.depth, b.parent]).slice(0, 1), [['Test', 3, 'FlyBot Lead']]) + const reused = JSON.parse(await h.tool('create_child_bot', { name: 'Test', role_description: 'runs the FlyBot tests' }, 'a')) + assert.equal(reused.reused?.path, 'Chief → FlyBot Lead → Test') + fork.dispose() + } finally { + await h.stop() + } +}) diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index a7b1a4ea..e50824a7 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -227,7 +227,8 @@ test('fix 4: create_child_bot enforces minLength/maxLength dropped by tools.regi assert.match((await c({ name: 'A', role_description: 'r'.repeat(1001) })).error, /role_description must be at most 1000/) assert.match((await c({ name: 'A' })).error, /role_description is required/) assert.match((await c({ name: 5, role_description: 'x' })).error, /name must be a string/) - assert.match((await c({ name: 'A', role_description: 'x', parent: 'bot9' })).error, /unknown argument: parent/) + assert.match((await c({ name: 'A', role_description: 'x', owner: 'bot9' })).error, /unknown argument: owner/) + assert.match((await c({ name: 'A', role_description: 'x', parent: 'bot9' })).error, /parent must be you or one of your descendants/) // #447 assert.equal(m.bots.size, 1) // nothing created assert.ok((await c({ name: 'x'.repeat(40), role_description: 'r'.repeat(1000) })).created.id) assert.match((await m.call(m.root, 'list_children', { junk: 1 })).error, /unknown argument/) @@ -1268,3 +1269,162 @@ test('#452 client: structure.include is false for archived and 归档-* names', assert.equal(entry.include('c'), true) dispose() }) + +// #447 (owner decisions 2026-10-19 ~19:45 and 21:27 Asia/Shanghai): forward allocation. Reuse before +// create (find_bots, same role anywhere in the branch), create_child_bot(parent?) only in the caller's +// own downstream with limits at the target, project teams as a lead plus children, all on #446's +// shared primitives; orphans are reattached, never left outside for good. +const child = (m, parent, name, role, extra = {}) => m.call(parent, 'create_child_bot', { name, role_description: role, ...extra }) +const kidsOf = (m, id) => m.tree.childrenOf(m.tree.snapshot(), id).map(c => c.name) + +test('#447: create_child_bot reuses a Bot with the same role at depth 3 instead of a duplicate; find_bots shows name, role, parent, depth', async () => { + const m = await setup() + const eng = (await child(m, m.root, 'Engineering', 'Builds and tests the software')).created.id + const tester = (await child(m, eng, 'Tester', 'Runs the test suite and reports failures')).created.id + const life = (await child(m, m.root, 'Life', 'Home and family')).created.id + const before = m.bots.size + for (const [name, role] of [['Tester', 'Runs the test suite and reports failures'], ['tester bot', 'Runs the test suite']]) { + const r = await child(m, m.root, name, role) + assert.equal(r.created, undefined, `${name}: nothing created`) + assert.equal(r.reused.id, tester) + assert.equal(r.reused.depth, 3) + assert.equal(r.reused.path, 'Chief → Engineering → Tester') + assert.equal(r.reused.via, 'Engineering') + assert.match(r.note, /already exists: Chief → Engineering → Tester\. Reuse it \(delegate through Engineering\)/) + } + assert.equal(m.bots.size, before) + // find_bots: the root sees the whole tree; a match at depth 3 comes first. + const found = await m.call(m.root, 'find_bots', { query: 'tester' }) + assert.equal(found.scope, 'the whole tree') + assert.deepEqual({ ...found.bots[0], role: undefined }, { id: tester, name: 'Tester', role: undefined, depth: 3, path: 'Chief → Engineering → Tester', parent: 'Engineering', children: 0, via: 'Engineering', match: 'same role' }) + assert.equal(found.bots[0].role, 'Runs the test suite and reports failures') + assert.ok((await m.call(m.root, 'find_bots', { query: 'test' })).bots.some(b => b.id === tester), 'a stem of a role word matches') + const all = await m.call(m.root, 'find_bots', {}) + assert.deepEqual(all.bots.map(b => [b.name, b.depth, b.parent]), [['Engineering', 2, 'Chief'], ['Life', 2, 'Chief'], ['Tester', 3, 'Engineering']]) + assert.deepEqual(all.limits, { maxDepth: 5, maxChildren: 8 }) + // A Bot sees and reuses only its own downstream. + const mine = await m.call(life, 'find_bots', { query: 'tester' }) + assert.equal(mine.scope, 'the branch under Life') + assert.deepEqual(mine.bots, []) + assert.match((await m.call(life, 'find_bots', { query: 5 })).error, /query must be a string/) + const own = await child(m, life, 'Tester', 'Runs the test suite and reports failures') + assert.equal(f(m, own.created.id, 'parent'), life, 'outside its branch: created; the daily tidy merges duplicates') + // A different role with the same name is not a match. + assert.ok((await child(m, m.root, 'Tester', 'Tastes recipes in the kitchen')).created.id) +}) + +test('#447: create_child_bot with parent accepts only the caller or its descendants; limits are checked at the target parent', async () => { + const m = await setup({ maxChildren: 3, maxDepth: 3 }) + const alpha = (await child(m, m.root, 'Alpha', 'a')).created.id + const beta = (await child(m, m.root, 'Beta', 'b')).created.id + // By name or id, under a descendant. + const a1 = await child(m, m.root, 'A1', 'one', { parent: 'alpha' }) + assert.equal(f(m, a1.created.id, 'parent'), alpha) + assert.deepEqual(a1.created.parent, { id: alpha, name: 'Alpha' }) + assert.equal(a1.created.path, 'Chief → Alpha → A1') + assert.equal(a1.created.depth, 3) + assert.equal(f(m, (await child(m, m.root, 'A2', 'two', { parent: alpha })).created.id, 'parent'), alpha) + assert.equal(f(m, (await child(m, alpha, 'A3', 'three', { parent: alpha })).created.id, 'parent'), alpha, 'the caller itself') + // Not the caller's downstream: a sibling, an ancestor, a stranger, an unknown name. Nothing created. + const size = m.bots.size + for (const parent of [beta, 'Beta', m.root, 'Chief', 'Nobody']) assert.match((await child(m, alpha, 'X', 'x', { parent })).error, /parent must be you or one of your descendants/, String(parent)) + const o = await outsider(m) + assert.match((await child(m, m.root, 'X', 'x', { parent: o })).error, /parent must be you or one of your descendants/) + assert.equal(m.bots.size, size + 1) + // Limits at the target, not at the caller (Chief still has room). + assert.match((await child(m, m.root, 'A4', 'x', { parent: 'Alpha' })).error, /limit reached: Alpha already has 3 children \(maxChildren=3\)/) + assert.match((await child(m, m.root, 'Deep', 'x', { parent: a1.created.id })).error, /limit reached: a child of A1 would be at depth 4 \(maxDepth=3\)/) + assert.match((await child(m, m.root, 'a1', 'dup', { parent: 'Alpha' })).error, /already exists under Alpha/) + // The per-parent lock: parallel creates under one target never pass maxChildren. + await Promise.all(Array.from({ length: 6 }, (_, i) => child(m, m.root, `B${i}`, `b${i}`, { parent: 'Beta' }))) + assert.equal(kidsOf(m, beta).length, 3) + assert.equal(kidsOf(m, m.root).length, 2) +}) + +test('#447: a project with several roles becomes a lead plus children, one slot under Chief; later Bots of the group join its lead', async () => { + const m = await setup() + for (const name of ['Cooking', 'Travel']) await make(m, m.root, name) + const before = m.bots.size + const roles = ['Dev: writes and fixes the FlyBot code', 'Test: runs the FlyBot test suite', 'Maintenance: keeps FlyBot dependencies and CI healthy', 'Repo: manages FlyBot issues and pull requests'] + const r = await m.call(m.root, 'create_team', { team: 'FlyBot', members: roles }) + assert.equal(r.error, undefined) + assert.deepEqual(kidsOf(m, m.root), ['Cooking', 'Travel', 'FlyBot Lead'], 'one new slot under Chief') + const lead = r.team.lead.id + assert.deepEqual(r.team.lead, { id: lead, name: 'FlyBot Lead', path: 'Chief → FlyBot Lead', created: true }) + assert.deepEqual(kidsOf(m, lead), ['Dev', 'Test', 'Maintenance', 'Repo']) + assert.equal(m.bots.size, before + 5, 'exactly the lead and its four members') + assert.deepEqual(f(m, lead, 'lead'), { key: 'project:flybot', label: 'FlyBot' }) + assert.match(f(m, lead, 'treeRole'), /^Lead of the FlyBot group: coordinates Dev, Test, Maintenance, Repo/) + assert.ok(r.team.members.every(x => x.created)) + assert.match(r.note, /Team: Chief → FlyBot Lead → \{Dev, Test, Maintenance, Repo\}/) + assert.match(sp(m, r.team.members[3].id), /Chief → FlyBot Lead → Repo/) + assert.match(sp(m, lead), /COORDINATOR[\s\S]*- Dev: writes and fixes the FlyBot code/) + assert.deepEqual(m.tree.overview().runs, [], 'a fresh team is a plain create, not a reorganization') + // A later Bot of the group goes under its lead (no parent given); one that could lead it stays at the caller. + const docs = await child(m, m.root, 'FlyBot Docs', 'Writes the FlyBot docs') + assert.equal(docs.created.path, 'Chief → FlyBot Lead → FlyBot Docs') + assert.equal(f(m, (await child(m, m.root, 'Translator', 'Translates texts')).created.id, 'parent'), m.root) + // Asking again reuses the lead and the members with the same role; only the new role is created. + const again = await m.call(m.root, 'create_team', { team: 'FlyBot', members: ['Dev: writes and fixes the FlyBot code', 'Release: ships FlyBot versions'] }) + assert.equal(again.team.lead.id, lead) + assert.equal(again.team.lead.reused, true) + assert.deepEqual(again.team.members.map(x => [x.name, Boolean(x.reused), Boolean(x.created)]), [['Dev', true, false], ['Release', false, true]]) + assert.deepEqual(kidsOf(m, m.root), ['Cooking', 'Travel', 'FlyBot Lead', 'Translator']) + // Chief's prompt states the placement rules and the automatic safety net. + assert.match(sp(m, m.root), /find_bots[\s\S]*create_team[\s\S]*6 or more of your 8 children[\s\S]*automatic reorganization/) + // Bad input. + assert.match((await m.call(m.root, 'create_team', { team: 'X', members: ['Solo: alone'] })).error, /at least 2 members/) + assert.match((await m.call(m.root, 'create_team', { team: 'X', members: 'Dev: x' })).error, /members must be a list/) + assert.match((await m.call(m.root, 'create_team', { team: 'X', members: ['Dev', 'Test: t'] })).error, /"Name: role"/) + assert.match((await m.call(m.root, 'create_team', { team: 'x'.repeat(36), members: ['A: a', 'B: b'] })).error, /team must be at most 35/) + assert.match((await m.call(Object.values(m.tree.snapshot()).find(n => n.name === 'Cooking').id, 'create_team', { team: 'X', members: ['A: a', 'B: b'], parent: lead })).error, /parent must be you or one of your descendants/) +}) + +test('#447: a team at a full level takes the slot of a flat member it reuses; the move is journaled and undoable; no room is refused', async () => { + const m = await setup({ maxChildren: 3 }) + for (const name of ['Cooking', 'Travel']) await make(m, m.root, name) + const dev = (await child(m, m.root, 'Dev', 'writes and fixes the FlyBot code')).created.id + assert.match((await m.call(m.root, 'create_team', { team: 'Garden', members: ['Planter: plants', 'Waterer: waters'] })).error, /limit reached: Chief already has 3 children[\s\S]*regroups this level by role on its own/) + const r = await m.call(m.root, 'create_team', { team: 'FlyBot', members: ['Dev: writes and fixes the FlyBot code', 'Test: runs the FlyBot test suite'] }) + assert.equal(r.error, undefined) + assert.deepEqual(kidsOf(m, m.root), ['Cooking', 'Travel', 'FlyBot Lead']) + assert.deepEqual(kidsOf(m, r.team.lead.id), ['Dev', 'Test']) + assert.deepEqual(r.team.members.map(x => [x.name, Boolean(x.reused), Boolean(x.moved)]), [['Dev', true, true], ['Test', false, false]]) + const run = m.tree.overview().runs[0] + assert.equal(run.trigger, 'team') + assert.deepEqual(run.summary, ['created FlyBot Lead under Chief to lead the FlyBot group', 'moved Dev from Chief to FlyBot Lead', 'placed new Test under FlyBot Lead']) + // Undo puts Dev back once Chief has room again; the lead keeps leading Test. + m.removeBot(Object.values(m.tree.snapshot()).find(n => n.name === 'Travel').id) + const u = await m.api('tree/undo', { runId: run.id }) + assert.ok(u.restored.includes('Dev back under Chief')) + assert.equal(f(m, dev, 'parent'), m.root) + assert.ok(u.kept.some(k => /FlyBot Lead: still leads Test/.test(k))) +}) + +test('#447: an orphan (its parent gone) is reattached by role, never left outside for good', async () => { + const m = await setup() + const team = await m.call(m.root, 'create_team', { team: 'FlyBot', members: ['Dev: writes the FlyBot code', 'Test: runs the FlyBot tests'] }) + const scoped = m.flybot.scope('flybot-tree') + const qa = await scoped.bot.create({ name: 'FlyBot QA', fields: { parent: 'gone', treeRole: 'Checks FlyBot releases' } }) + const plain = await scoped.bot.create({ name: 'Poet', fields: { parent: 'gone-too', treeRole: 'Writes poems' } }) + await m.tree.ensureRoot(); await m.tree.settled() + assert.equal(f(m, qa, 'parent'), team.team.lead.id, 'into the lead of its group') + assert.equal(f(m, plain, 'parent'), m.root, 'else under the root') + const lines = m.tree.overview().runs.flatMap(r => r.summary) + assert.ok(lines.includes('moved FlyBot QA from a removed Bot to FlyBot Lead'), 'journaled (undoable)') + assert.ok(lines.includes('moved Poet from a removed Bot to Chief')) + assert.match(sp(m, qa), /Chief → FlyBot Lead → FlyBot QA/) +}) + +test('#447: without parent, a new Bot goes only down leads of groups it belongs to', async () => { + const m = await setup() + const team = await m.call(m.root, 'create_team', { team: 'FlyBot', members: ['Dev: writes the FlyBot code', 'Test: runs the FlyBot tests'] }) + const quality = (await child(m, m.root, 'Code Quality', 'reviews code and lint', { parent: 'FlyBot Lead' })).created.id + m.flybot.scope('flybot-tree').fields.set(quality, 'lead', { key: 'domain:dev', label: 'Dev' }) + // A dev Bot that is not FlyBot's does not end up inside FlyBot's dev group. + assert.equal(f(m, (await child(m, m.root, 'Code Reviewer', 'reviews pull requests')).created.id, 'parent'), m.root) + // A FlyBot Bot goes under the FlyBot lead (the project group first). + assert.equal(f(m, (await child(m, m.root, 'FlyBot Linter', 'lints FlyBot code')).created.id, 'parent'), team.team.lead.id) + // Asked from inside FlyBot Lead, its dev group takes a dev Bot. + assert.equal(f(m, (await child(m, team.team.lead.id, 'Style Checker', 'checks code style')).created.id, 'parent'), quality) +})