diff --git a/changelog.d/895-tree-load-line.fixed.md b/changelog.d/895-tree-load-line.fixed.md new file mode 100644 index 00000000..d9a4bf0a --- /dev/null +++ b/changelog.d/895-tree-load-line.fixed.md @@ -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)). diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index e3c9262d..c0e65c13 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -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) @@ -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 diff --git a/plugins/tree/index.js b/plugins/tree/index.js index 2752b496..e7f11f7a 100644 --- a/plugins/tree/index.js +++ b/plugins/tree/index.js @@ -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) @@ -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) }), diff --git a/plugins/tree/src/prompt.js b/plugins/tree/src/prompt.js index cbfdceac..4ca4b161 100644 --- a/plugins/tree/src/prompt.js +++ b/plugins/tree/src/prompt.js @@ -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') diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index bbc3a0cc..f8329327 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -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']) @@ -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 @@ -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) diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index e285a5e3..25ca9309 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -1534,18 +1534,24 @@ test('#447: without parent, a new Bot goes only down leads of groups it belongs 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 () => { +test('#507, #895: a child\'s running turns show as load in list_children, and the coordinator\'s prompt stays byte-stable', async () => { const m = await setup() const work = await make(m, m.root, 'Work') + const before = sp(m, m.root) + // The coordinator is told where load is, once, in words that do not change with it. + assert.match(before, /When more than one child fits a part, call list_children before you send it: it shows each child's load right now/) 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\)/) + // A child's turn boundary leaves the system prompt alone: a load line there changed the whole + // prompt at every boundary, and DSH re-sent all of it on the coordinator's next step. + assert.equal(sp(m, m.root), before) + assert.doesNotMatch(sp(m, m.root), /Child load/) 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/) + assert.deepEqual((await m.call(m.root, 'list_children')).children.map(c => [c.name, c.load]), [['Work', 0]]) + assert.equal(sp(m, m.root), before) }) test('#624: a turn that never ends shows how long it has been open, and load stays a number', async () => { @@ -1560,14 +1566,14 @@ test('#624: a turn that never ends shows how long it has been open, and load sta assert.equal(item.openForMs, 3 * 60_000) assert.equal(item.stalled, undefined, 'three minutes is an ordinary turn') m.tree.refreshPrompt(m.root) - assert.match(sp(m, m.root), /Work: working\./) + assert.doesNotMatch(sp(m, m.root), /Work: (working|busy|idle)/, 'load and age are list_children\'s, never the prompt\'s (#895)') // 44 minutes later the turn is still open and now reads as what the issue is about. clock += 44 * 60_000 item = (await m.call(m.root, 'list_children')).children.find(c => c.id === work) assert.equal(item.openForMs, 47 * 60_000) assert.equal(item.stalled, true) m.tree.refreshPrompt(m.root) - assert.match(sp(m, m.root), /Work: working \(47 min, no turn ended\)/) + assert.doesNotMatch(sp(m, m.root), /Work: (working|busy|idle)/, 'load and age are list_children\'s, never the prompt\'s (#895)') m.feed('turn.end', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 2 }) item = (await m.call(m.root, 'list_children')).children.find(c => c.id === work) assert.equal(item.load, 0) @@ -1619,7 +1625,7 @@ test('#696: two turns open, the later one ends, and the age left is the older tu assert.equal(item.openForMs, 12 * 60_000, 'not the 2 minutes of the turn that ended') assert.equal(item.stalled, true) m.tree.refreshPrompt(m.root) - assert.match(sp(m, m.root), /Work: working \(12 min, no turn ended\)/) + assert.doesNotMatch(sp(m, m.root), /Work: (working|busy|idle)/, 'load and age are list_children\'s, never the prompt\'s (#895)') m.feed('turn.end', { botId: work, place: 'chat', sessionId: 's', turnId: 's#1', turn: 1, time: 4 }) item = (await m.call(m.root, 'list_children')).children.find(c => c.id === work) assert.equal(item.load, 0)