Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/tree-load.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A tree coordinator can now see how busy its children are. Each Bot's running turns are counted from the activity feed, shown as one line in the coordinator's prompt (`Child load right now — Work: busy (2 running), Travel: idle`) and a `load` count on each entry of `list_children`, so a new part goes to a free child instead of a busy one. Nothing is stored; the counts start from zero on each start.
11 changes: 9 additions & 2 deletions docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers).
| `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). |
| `plugins/tree/src/load.js` | Runtime load (#507): pure `createLoadTracker`/`loadWord`/`loadLine` for the open turns each Bot runs, fed from the activity feed. |

## Entry points

Expand Down Expand Up @@ -70,7 +71,13 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent
- **The owner is told afterwards**, no plan card first: the notice in the tree view, and the Main Bot's prompt names the last run (within 7 days, not undone) so Chief can say what moved.
- **No silent pull-up**: freeing a slot moves nobody up; the next new Bot takes it. Activity is metadata only (when, which tool names), never message text.

### Optional Tree section toggle
### Runtime load (#507)

A coordinator can see which of its children are busy right now, so it sends a new part to a free one instead of stacking work on a busy one.

- `src/load.js` (pure) counts the open turns per Bot from the core activity feed (`turn.start` / `turn.end`, ids and times only). `index.js` subscribes both events; nothing is persisted (a restart starts from zero).
- The coordinator's prompt gains one line — `Child load right now — Work: busy (2 running), Travel: idle.` — and `list_children` carries a `load` count per child. A child's turn boundary rewrites its parent's prompt through `refreshPrompt` (the marked block only, and `writeBlock` skips an unchanged block), so the line stays current; `list_children` reads the tracker live.


- 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.
- The right-click menu entry from `menuItems({ bot })` is a single **Copy invite link** (bare URL, shown when `social.canInvite(bot.id)`). The invite with instructions is offered only in the Bot's Sharing section, so the menu does not list the same invite twice (#173).
Expand All @@ -81,7 +88,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 `#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.
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. Runtime load (#507) is covered by `test/load.test.js` (pure) and a `test/tree.test.js` case: a child's running turns show in the coordinator's stored prompt and `list_children`, and drop to zero at `turn.end`.

## Common mistakes

Expand Down
11 changes: 11 additions & 0 deletions plugins/tree/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export function apply(ctx, config = {}) {
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, classify: config.classify, tidy: config.tidy, now: typeof config.now === 'function' ? config.now : undefined })
// A child's turn boundary changes its load; rewrite its parent's prompt so the Child-load line a
// coordinator reads is current (#507). writeBlock skips an unchanged block, so nothing churns.
const refreshParentPrompt = (id) => {
const parent = tree.parentOf(id)
if (typeof parent === 'string' && tree.isMember(parent)) tree.refreshPrompt(parent)
}
const disposers = [
fb.useSend('tree', treeSendMiddleware(tree, { bypassSocial: config.bypassSocial === true, isRemote: fb.isRemote, hasAnyGrant: fb.hasAnyGrant })),
...registerTreeTools(fb, tree),
Expand All @@ -23,6 +29,11 @@ export function apply(ctx, config = {}) {
// Recent activity (when, which tools) feeds idle detection and role grouping (#446).
fb.onFeed('turn.start', e => tree.noteActivity(e?.botId)),
fb.onFeed('tool.start', e => tree.noteActivity(e?.botId, e?.name)),
// Runtime load: how many turns each Bot runs now, so a coordinator routes to a free child (#507).
// A change is also written into the child's parent prompt, so the Child-load line a coordinator
// reads stays current (writeBlock only rewrites the block when the line really changed).
fb.onFeed('turn.start', e => { tree.noteTurnStart(e?.botId); refreshParentPrompt(e?.botId) }),
fb.onFeed('turn.end', e => { tree.noteTurnEnd(e?.botId); refreshParentPrompt(e?.botId) }),
// The tree view: what the last reorganization did, one-click undo, dismiss.
fb.handleApi('tree/reorg', () => tree.overview(), { read: true }),
fb.handleApi('tree/undo', payload => tree.undo(typeof payload?.runId === 'string' ? payload.runId : undefined)),
Expand Down
44 changes: 44 additions & 0 deletions plugins/tree/src/load.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Runtime load of tree Bots (#507): how many turns each Bot is running right now, so a
// coordinator can route a new part to a child that is free instead of stacking work on a busy
// one. The core's activity feed (turn.start / turn.end) is the only input; it carries ids and
// times, never text. Pure and process-local: nothing here is persisted.
export function createLoadTracker() {
/** @type {Map<string, number>} open turns per Bot id */
const open = new Map()
return {
start(id) {
if (typeof id !== 'string' || id === '') return
open.set(id, (open.get(id) ?? 0) + 1)
},
end(id) {
if (typeof id !== 'string' || id === '') return
const next = (open.get(id) ?? 0) - 1
if (next <= 0) open.delete(id)
else open.set(id, next)
},
of(id) {
return open.get(id) ?? 0
},
snapshot() {
return Object.fromEntries(open)
},
}
}

/** How a load count reads to the model: idle, working, or busy with the running count. */
export function loadWord(count) {
const n = Number(count) || 0
if (n <= 0) return 'idle'
if (n === 1) return 'working'
return `busy (${n} running)`
}

/**
* One prompt line for a coordinator: which of its direct children are free and which are busy,
* so it can send a part to an idle one. Undefined when the Bot has no children (nothing to route).
*/
export function loadLine(children, countOf) {
if (!Array.isArray(children) || children.length === 0) return undefined
const parts = children.map(child => `${child.name}: ${loadWord(countOf(child.id))}`)
return `Child load right now — ${parts.join(', ')}. Send a new part to a child that is idle or working before one that is busy.`
}
2 changes: 1 addition & 1 deletion plugins/tree/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function registerTreeTools(fb, tree) {
if (bad) return fail(bad)
const nodes = tree.snapshot()
if (!nodes[botId]) return fail('this bot is not part of the tree')
return { children: tree.childrenOf(nodes, botId).map(({ id, name, role }) => ({ id, name, role })), depth: tree.depthOf(nodes, botId), limits: tree.limits }
return { children: tree.childrenOf(nodes, botId).map(({ id, name, role }) => ({ id, name, role, load: tree.loadOf(id) })), depth: tree.depthOf(nodes, botId), limits: tree.limits }
},
}),
fb.registerTool({
Expand Down
15 changes: 13 additions & 2 deletions plugins/tree/src/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { systemPrompt, withTreeBlock, hasTreeBlock } from './prompt.js'
import { forestOf } from './forest.js'
import { createOrganizer } from './organize.js'
import { createAllocator } from './allocate.js'
import { createLoadTracker, loadLine } from './load.js'
export { forestOf }

export const TREE_KEYS = new Set(['parent', 'treeRole'])
Expand All @@ -21,6 +22,7 @@ const ROOT_LABEL = 'Main bot and root of the tree'

export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExisting = true, warn = () => {}, classify, tidy, now = Date.now } = {}) {
const listeners = new Set()
const load = createLoadTracker()

function snapshot() {
const nodes = {}
Expand All @@ -38,7 +40,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), limits })
return n && systemPrompt({ name: n.name, role: n.role, trace: traceOf(nodes, id), children: childrenOf(nodes, id), root: n.root, notes: [...notesFor(id, n), ...loadNotes(id, nodes)], 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
Expand All @@ -56,6 +58,12 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis
if (Array.isArray(merged) && merged.length) notes.push(`You took over the role of ${merged.map(m => m.name).join(', ')} (merged into you; the old Bot is archived with its own chat history).`)
return notes
}
// The runtime load of a coordinator's direct children (#507), as one prompt line, so it can
// send a new part to a free child instead of a busy one.
function loadNotes(id, nodes) {
const line = loadLine(childrenOf(nodes, id), childId => load.of(childId))
return line === undefined ? [] : [line]
}
// Prompt injection: only the tree's marked block is replaced; other text (e.g. Chief's own) stays.
const writeBlock = (id, block) => {
const current = fb.getSystemPrompt(id)
Expand Down Expand Up @@ -240,5 +248,8 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis

const { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, idle: settled, repairArchiveNames } = organizer
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 }
const noteTurnStart = id => load.start(id)
const noteTurnEnd = id => load.end(id)
const loadOf = id => load.of(id)
return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, noteTurnStart, noteTurnEnd, loadOf, refreshPrompt, parentOf, 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 }
}
26 changes: 26 additions & 0 deletions plugins/tree/test/load.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createLoadTracker, loadLine, loadWord } from '../src/load.js'

test('the load tracker counts open turns and floors at zero', () => {
const load = createLoadTracker()
assert.equal(load.of('a'), 0)
load.start('a'); load.start('a')
assert.equal(load.of('a'), 2)
assert.deepEqual(load.snapshot(), { a: 2 })
load.end('a')
assert.equal(load.of('a'), 1)
load.end('a'); load.end('a')
assert.equal(load.of('a'), 0, 'an extra end never goes negative')
load.start(''); load.end(undefined)
assert.deepEqual(load.snapshot(), {})
})

test('loadWord and loadLine read plainly, and loadLine is undefined with no children', () => {
assert.equal(loadWord(0), 'idle')
assert.equal(loadWord(1), 'working')
assert.equal(loadWord(3), 'busy (3 running)')
assert.equal(loadLine([], () => 0), undefined)
const line = loadLine([{ id: 'a', name: 'Work' }, { id: 'b', name: 'Travel' }], id => (id === 'a' ? 2 : 0))
assert.match(line, /Work: busy \(2 running\), Travel: idle/)
})
2 changes: 1 addition & 1 deletion plugins/tree/test/mock-flybot.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

export function createMockFlybot() {
const bots = new Map() // id -> { id, name, systemPrompt, fields: { [plugin]: {...} } }
const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.grants': new Set(), 'turn.start': new Set(), 'tool.start': new Set() }
const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.grants': new Set(), 'turn.start': new Set(), 'turn.end': new Set(), 'tool.start': new Set() }
const apis = new Map()
const middlewares = []
const tools = new Map()
Expand All @@ -30,7 +30,7 @@
let seq = 0
const granted = b => (grants.get(b?.id) ?? []).some(g => !g.roomId)
const view = b => b && { id: b.id, name: b.name, isMain: b.isMain === true, granted: granted(b), createdAt: b.createdAt, createdBy: null, systemPrompt: b.systemPrompt ?? '', fields: clone(b.fields), ...(b.hidden === true ? { hidden: true } : {}) }
const fire = (ev, payload) => { for (const fn of [...listeners[ev]]) fn({ ...payload }) }

Check warning on line 33 in plugins/tree/test/mock-flybot.js

View workflow job for this annotation

GitHub Actions / plugins (tree)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
const onFor = plugin => (ev, fn, { all = false } = {}) => {
if (!listeners[ev]) throw new Error(`flybot: unknown event ${ev}`)
const l = ev === 'change' && !all ? c => { if (c.plugin === plugin) fn(c) } : fn
Expand Down
14 changes: 14 additions & 0 deletions plugins/tree/test/tree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1428,3 +1428,17 @@ test('#447: without parent, a new Bot goes only down leads of groups it belongs
// 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)
})

test('#507: a child\'s running turns show as load in the coordinator\'s stored prompt and list_children', async () => {
const m = await setup()
const work = await make(m, m.root, 'Work')
m.feed('turn.start', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 1 })
m.feed('turn.start', { botId: work, place: 'chat', sessionId: 's', turnId: 't2', turn: 2, time: 2 })
// The coordinator's prompt is rewritten at the child's turn boundary, not only read live.
assert.match(sp(m, m.root), /Child load right now — Work: busy \(2 running\)/)
assert.deepEqual((await m.call(m.root, 'list_children')).children.map(c => [c.name, c.load]), [['Work', 2]])
m.feed('turn.end', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 3 })
m.feed('turn.end', { botId: work, place: 'chat', sessionId: 's', turnId: 't2', turn: 2, time: 4 })
assert.equal(m.tree.loadOf(work), 0)
assert.match(sp(m, m.root), /Child load right now — Work: idle/)
})
Loading