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/895-tree-load-line.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A tree coordinator no longer gets its whole system prompt re-sent every time one of its children starts or ends a turn: children's load is read with `list_children` instead of a prompt line that changed at each boundary ([#895](https://github.com/ThinkFlowLab/FlyBot/issues/895)).
6 changes: 3 additions & 3 deletions docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,14 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent
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). A removed Bot's turns can never end, so `onBotRemoved` calls `clear(id)` and the count for that id reads 0 again, while a live Bot keeps its own records (#732).
- 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 same boundary (`turn.start` / `turn.end`) rewrites the running Bot's own prompt too, so a rename or role edit made in the core reaches its own prompt and, through the parent refresh, its coordinator's child list on the next turn instead of only after a restart.
- `list_children` carries a `load` count per child, read from the tracker live. The coordinator's prompt does **not** carry the load (#895): it once had a `Child load right now — Work: busy (2 running), Travel: idle.` line, which made the whole system prompt differ after every child turn start and end (and every minute for a stalled turn), and DSH then re-sent the full system prompt on the coordinator's next step (appended to the history on DeepSeek's `in-history` route, a whole-history cache miss elsewhere). The coordinator rules say instead, in fixed words, to call `list_children` before sending a part that more than one child fits, and to prefer an idle or working child to a busy one (load only breaks a tie between children whose scope fits, so a part with one fitting child costs no extra call). A child's turn boundary still rewrites its parent's prompt through `refreshPrompt` (the marked block only, and `writeBlock` skips an unchanged block), and the running Bot's own prompt too, so a rename or role edit made in the core reaches its own prompt and its coordinator's child list on the next turn instead of only after a restart; with no load in the block, a boundary that changed nothing else writes nothing.

#### How long a turn has been open (#624)

A count alone cannot tell a long turn from a stuck one: `load: 1` read the same for a turn three seconds in and for one that had been open 47 minutes without consuming its queue.

- The tracker keeps each open turn's start time, so `openFor(id)` answers the age of the **oldest** open turn and `0` when the Bot is idle. An end closes **its own** turn: `index.js` hands the feed's `turnId` to `end(id, turnId)`, because dropping whichever turn was oldest left the *newer* turn's age behind when a later turn ended first (#696). No match — an id never seen, or none at all — still drops exactly one turn, the oldest, so the count cannot drift.
- Past `STALLED_AFTER_MS` (12 minutes) the coordinator's line says the age instead of just the count — `Work: working (47 min, no turn ended)`, or `Work: busy (2 running, oldest open 47 min)`. `list_children` gains `openForMs` (and `stalled: true` past the threshold) only for a child with a turn open, so an idle child keeps exactly its old shape and every existing reader of `load` keeps a number. The tool's description names those fields too (#733): a model reads the description, not this code, and a coordinator that never learns `stalled` exists cannot route around it.
- Past `STALLED_AFTER_MS` (12 minutes) `loadLine` (pure, kept for readers that want one line; the prompt no longer uses it, #895) says the age instead of just the count — `Work: working (47 min, no turn ended)`, or `Work: busy (2 running, oldest open 47 min)`. `list_children` gains `openForMs` (and `stalled: true` past the threshold) only for a child with a turn open, so an idle child keeps exactly its old shape and every existing reader of `load` keeps a number. The tool's description names those fields too (#733): a model reads the description, not this code, and a coordinator that never learns `stalled` exists cannot route around it.
- **A hint for routing, never a gate.** The tree plugin cannot see a Bot's inbox, so a legitimately long turn ages too; nothing here refuses, ends or reassigns work. `loadLine`'s third argument is optional, so a caller that only has a count keeps the old line.

### Model-based clustering (#450 item 2)
Expand Down Expand Up @@ -126,7 +126,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; a name that is already taken anywhere refusing the whole team with no Bot created, no journal entry and the tree byte-identical), 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`. The archived sidebar section and the concurrent burst (#810) are covered by `test/archived-section.test.js` (nothing at N=0, the collapsed header, a merged tag, a `归档-` name, a row opening the chat, and the structure entry still hiding all of them) and by `test/tree.test.js` against a fake `fb` that holds its creates open (one lead and two children for one burst, never a second lead, an existing lead reused, different keys and the `burstGrouping: false` control landing as before, a serial call unchanged, an explicit parent opting out, a burst whose member was reused — no lead for one new member, and no lead naming a Bot it does not hold (#819), and a burst of two same-name same-role calls — one member, one lead, that name in the lead's role text once (#840)). The age of an open turn (#624) is covered in both: `openFor` reads the oldest start and falls back to the newer turn once one ends, an end that names its turn closes that one and not the oldest (#696, unknown and missing ids included), the line names the age only past the threshold and never calls an idle child stalled, and `list_children` adds `openForMs`/`stalled` for an open turn while an idle child keeps the old shape.
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; a name that is already taken anywhere refusing the whole team with no Bot created, no journal entry and the tree byte-identical), 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 `list_children` and drop to zero at `turn.end`, while the coordinator's prompt stays byte-identical across those boundaries (#895). The archived sidebar section and the concurrent burst (#810) are covered by `test/archived-section.test.js` (nothing at N=0, the collapsed header, a merged tag, a `归档-` name, a row opening the chat, and the structure entry still hiding all of them) and by `test/tree.test.js` against a fake `fb` that holds its creates open (one lead and two children for one burst, never a second lead, an existing lead reused, different keys and the `burstGrouping: false` control landing as before, a serial call unchanged, an explicit parent opting out, a burst whose member was reused — no lead for one new member, and no lead naming a Bot it does not hold (#819), and a burst of two same-name same-role calls — one member, one lead, that name in the lead's role text once (#840)). The age of an open turn (#624) is covered in both: `openFor` reads the oldest start and falls back to the newer turn once one ends, an end that names its turn closes that one and not the oldest (#696, unknown and missing ids included), the line names the age only past the threshold and never calls an idle child stalled, and `list_children` adds `openForMs`/`stalled` for an open turn while an idle child keeps the old shape.

## Common mistakes

Expand Down
10 changes: 5 additions & 5 deletions plugins/tree/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ 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, burstGrouping: config.burstGrouping, 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.
// A child's turn boundary rewrites its parent's prompt, so a rename or role edit reaches the
// coordinator's child list. The block carries no load (#895: list_children reads that live), so
// writeBlock finds it unchanged and nothing churns.
const refreshParentPrompt = (id) => {
const parent = tree.parentOf(id)
if (typeof parent === 'string' && tree.isMember(parent)) tree.refreshPrompt(parent)
Expand Down Expand Up @@ -51,9 +52,8 @@ 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).
// Runtime load: how many turns each Bot runs now, so a coordinator routes to a free child (#507);
// list_children reads it live, and the prompt never carries it (#895).
// The feed names the turn it is about (turnId); dropping it made an end close whichever turn was
// oldest, so with two open and the later one ending first, openFor reported the newer age (#696).
fb.onFeed('turn.start', e => { tree.noteTurnStart(e?.botId, e?.turnId); reconcileRoot(); refreshPrompts(e?.botId) }),
Expand Down
3 changes: 3 additions & 0 deletions plugins/tree/src/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function systemPrompt({ name, role, trace, children, root = false, notes
'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 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.',
// #895: load is read through list_children, not written here: a line that changed at every child turn boundary
// changed this whole system prompt, and DSH then re-sent all of it.
' When more than one child fits a part, call list_children before you send it: it shows each child\'s load right now (turns running, and how long an open turn has been open). Send it to a child that is idle or working before one that is busy.',
'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]), ...placementRules(limits), tools, ...notes].join('\n')
Expand Down
10 changes: 2 additions & 8 deletions plugins/tree/src/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +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'
import { createLoadTracker } from './load.js'
export { forestOf }

export const TREE_KEYS = new Set(['parent', 'treeRole'])
Expand Down Expand Up @@ -50,7 +50,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), ...loadNotes(id, nodes)], 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), 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 @@ -68,12 +68,6 @@ 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), childId => load.openFor(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
Loading
Loading