Skip to content
Closed
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-plan.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A tree coordinator can now keep a delegation plan. `set_plan(goal, parts)` records the split before it delegates, each child reports progress with `report_part` (todo / doing / done / blocked), and `read_plan` shows a Bot its own plan and the one its parent gave it. The coordinator's prompt shows the plan as one line — what is done, what is still open, and what is blocked — so a split task is tracked instead of held in one prompt's memory.
12 changes: 10 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/plan.js` | The delegation plan (#TBD): pure `makePlan`/`reportPart`/`planLine` for the coordinator's split and its children's progress reports. |

## Entry points

Expand Down Expand Up @@ -70,7 +71,14 @@ 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
### Delegation plan (#TBD)

A coordinator may record its split of a task before it delegates, so a child can report progress back to it and the coordinator sees what is still open.

- Three tools, visible to every tree member: `set_plan({ goal, parts })` saves the caller's plan (replacing any earlier one); `report_part({ part, status, note })` updates a part (a child reports to its parent's plan, a coordinator to its own), `status` one of `todo`/`doing`/`done`/`blocked`; `read_plan()` reads the caller's own plan and its parent's. `src/plan.js` (pure) validates and words them.
- The plan lives in the coordinator's own field `flybot-tree/plan`; a part's id (`p1`…) is stable within a plan and a report may name a part by its id or its name. The model caps and per-field writes match the tree's other fields.
- The coordinator's prompt shows one line: `Your delegation plan for "<goal>" (n/m done). Still open: …`, with blocked parts called out. It is refreshed through the same `refreshPrompt` path as every other tree note, so the marked block is replaced, never the user's own text.


- 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 +89,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. The delegation plan (#TBD) is covered by `test/plan.test.js` (pure `makePlan`/`reportPart`/`planLine`) and a `test/tree.test.js` case: a coordinator's `set_plan` shows in its prompt, a child's `report_part` updates the parent's plan and the prompt line, `read_plan` shows own and parent, and bad arguments return an error.

## Common mistakes

Expand Down
74 changes: 74 additions & 0 deletions plugins/tree/src/plan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// The delegation plan (#TBD): a coordinator Bot's split of a task into parts, and the
// progress its children report back. It lives in the coordinator's own field
// `flybot-tree/plan`, so the tree view and the coordinator's prompt can show it, and a child
// reports through its parent edge without any new messaging.
//
// Pure: makePlan validates and normalizes, reportPart applies one report, planLine words the
// one-line view that goes into the coordinator's prompt. Nothing here touches storage.
export const PLAN_KEY = 'plan'
export const PLAN_MAX_PARTS = 12
const STATUSES = ['todo', 'doing', 'done', 'blocked']
export const PLAN_STATUSES = Object.freeze(STATUSES)
const GOAL_MAX = 300
const NAME_MAX = 60
const NOTE_MAX = 200

const clean = value => String(value ?? '').replace(/\s+/g, ' ').trim()
const norm = value => clean(value).toLowerCase()
const statusOf = value => (STATUSES.includes(String(value ?? '').toLowerCase()) ? String(value).toLowerCase() : undefined)

/**
* A new plan from the model's arguments. `parts` is a list of names, or `{ name, owner? }`.
* Resolves `{ plan }` or `{ error }`. Each part gets a stable id `p1`… so a report can name it.
*/
export function makePlan({ goal, parts }, now = Date.now) {
const cleanGoal = clean(goal).slice(0, GOAL_MAX)
if (cleanGoal === '') return { error: 'a plan needs a goal' }
if (!Array.isArray(parts) || parts.length === 0) return { error: 'a plan needs at least one part' }
if (parts.length > PLAN_MAX_PARTS) return { error: `a plan holds at most ${PLAN_MAX_PARTS} parts` }
const seen = new Set()
const out = []
for (const [index, raw] of parts.entries()) {
const name = clean(typeof raw === 'string' ? raw : raw?.name).slice(0, NAME_MAX)
const owner = clean(typeof raw === 'object' ? raw?.owner : '').slice(0, NAME_MAX)
if (name === '') return { error: `part ${index + 1} needs a name` }
if (seen.has(norm(name))) return { error: `two parts are named "${name}"` }
seen.add(norm(name))
out.push({ id: `p${index + 1}`, name, ...(owner ? { owner } : {}), status: 'todo' })
}
return { plan: { goal: cleanGoal, parts: out, at: now() } }
}

/**
* Applies one report to a plan. `part` matches a part id (`p2`) or its name. Resolves
* `{ plan, part }` or `{ error }`. The plan is copied, never mutated in place.
*/
export function reportPart(plan, { part, status, note }, now = Date.now) {
if (plan === undefined || plan === null || !Array.isArray(plan.parts)) return { error: 'there is no plan to report on' }
const wanted = norm(part)
if (wanted === '') return { error: 'name the part to report on (its id or its name)' }
const at = plan.parts.find(item => item.id === String(part).trim() || norm(item.name) === wanted)
if (at === undefined) return { error: `no part in the plan is "${clean(part)}"` }
const next = statusOf(status)
if (next === undefined) return { error: `status must be one of ${STATUSES.join(', ')}` }
const cleanNote = clean(note).slice(0, NOTE_MAX)
const updated = { ...at, status: next, ...(cleanNote ? { note: cleanNote } : {}), at: now() }
const parts = plan.parts.map(item => (item === at ? updated : item))
return { plan: { ...plan, parts, at: now() }, part: updated }
}

/**
* The one-line prompt view: how far the plan is, and what is still open. Undefined when
* there is no plan. `blocked` parts are called out so a stuck part is not missed.
*/
export function planLine(plan) {
if (plan === undefined || plan === null || !Array.isArray(plan.parts) || plan.parts.length === 0) return undefined
const done = plan.parts.filter(item => item.status === 'done').length
const blocked = plan.parts.filter(item => item.status === 'blocked')
const open = plan.parts.filter(item => item.status !== 'done')
const head = `Your delegation plan for "${plan.goal}" (${done}/${plan.parts.length} done).`
if (open.length === 0) return `${head} Every part is done; combine their results.`
const shown = open.slice(0, PLAN_MAX_PARTS).map(item => `${item.name} [${item.status}${item.owner ? ` · ${item.owner}` : ''}]`).join('; ')
const stuck = blocked.length > 0 ? ` Blocked: ${blocked.map(item => item.name).join(', ')}.` : ''
return `${head} Still open: ${shown}.${stuck}`
}
69 changes: 69 additions & 0 deletions plugins/tree/src/tools.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MAX_NAME_LENGTH } from './limits.js' // = ds-bot roster's 40-character name truncation
import { MAX_TEAM_LENGTH } from './allocate.js'
import { PLAN_KEY, makePlan, planLine, reportPart } from './plan.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
Expand Down Expand Up @@ -58,6 +59,26 @@ export const CREATE_TEAM_SCHEMA = Object.freeze({
required: ['team', 'members'],
additionalProperties: false,
})
export const SET_PLAN_SCHEMA = Object.freeze({
type: 'object',
properties: {
goal: { type: 'string', minLength: 1, maxLength: 300, description: 'One line: what the whole task is.' },
parts: { type: 'array', items: { type: 'string' }, description: 'The parts you split it into, one short name each, in the wording a child will recognize. At most 12.' },
},
required: ['goal', 'parts'],
additionalProperties: false,
})
export const REPORT_PART_SCHEMA = Object.freeze({
type: 'object',
properties: {
part: { type: 'string', minLength: 1, maxLength: 60, description: 'The part you report on: its id (p2) or its name.' },
status: { type: 'string', enum: ['todo', 'doing', 'done', 'blocked'], description: 'done when finished; blocked when it cannot go on.' },
note: { type: 'string', maxLength: 200, description: 'One short line of result or reason, when it helps.' },
},
required: ['part', 'status'],
additionalProperties: false,
})
export const READ_PLAN_SCHEMA = Object.freeze({ type: 'object', properties: {}, additionalProperties: false })

export function registerTreeTools(fb, tree) {
const inTree = botId => tree.isMember(botId) // visibleTo is sync
Expand Down Expand Up @@ -109,5 +130,53 @@ export function registerTreeTools(fb, tree) {
try { return await tree.createTeam(botId, { team: args.team, members: args.members, parent: args.parent }) } catch (e) { return fail(e) }
},
}),
fb.registerTool({
name: 'set_plan',
description: 'Record your split of a task into parts before you delegate (a coordinator). Each part gets an id (p1…) so a child can report with report_part. Replaces any earlier plan of yours.',
parameters: SET_PLAN_SCHEMA,
visibleTo: inTree,
async execute(args, { botId }) {
const bad = validateArgs(SET_PLAN_SCHEMA, args)
if (bad) return fail(bad)
const { plan, error } = makePlan({ goal: args.goal, parts: args.parts })
if (error) return fail(error)
fb.setField(botId, PLAN_KEY, plan)
tree.refreshPrompt(botId)
return { plan: plan.parts.map(item => ({ id: item.id, name: item.name })), note: `Plan saved (${plan.parts.length} parts). Delegate each part to the child whose scope fits; each reports back with report_part.` }
},
}),
fb.registerTool({
name: 'report_part',
description: 'Report progress on a plan part. A child reports to the plan of its parent; a coordinator reports on its own plan. status: todo, doing, done or blocked.',
parameters: REPORT_PART_SCHEMA,
visibleTo: inTree,
async execute(args, { botId }) {
const bad = validateArgs(REPORT_PART_SCHEMA, args)
if (bad) return fail(bad)
const own = fb.getField(botId, PLAN_KEY)
const parent = tree.parentOf(botId)
const targetId = (own !== undefined && own !== null) || !tree.isMember(parent) ? botId : parent
const { plan, part, error } = reportPart(fb.getField(targetId, PLAN_KEY), { part: args.part, status: args.status, note: args.note })
if (error) return fail(error)
fb.setField(targetId, PLAN_KEY, plan)
tree.refreshPrompt(targetId)
const who = targetId === botId ? 'your plan' : `${tree.snapshot()[targetId]?.name ?? 'your parent'}'s plan`
return { part: { id: part.id, name: part.name, status: part.status }, note: `Reported "${part.name}" as ${part.status}, on ${who}.` }
},
}),
fb.registerTool({
name: 'read_plan',
description: 'Read your own plan, and as a child the plan your parent made, with each part id, name, owner and status.',
parameters: READ_PLAN_SCHEMA,
visibleTo: inTree,
async execute(args, { botId }) {
const bad = validateArgs(READ_PLAN_SCHEMA, args ?? {})
if (bad) return fail(bad)
const own = fb.getField(botId, PLAN_KEY) ?? null
const parent = tree.parentOf(botId)
const fromParent = tree.isMember(parent) ? (fb.getField(parent, PLAN_KEY) ?? null) : null
return { plan: own, parent: fromParent, note: own ? (planLine(own) ?? 'Your plan is empty.') : 'You have no plan. Set one with set_plan before delegating.' }
},
}),
]
}
11 changes: 9 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 { PLAN_KEY, planLine } from './plan.js'
export { forestOf }

export const TREE_KEYS = new Set(['parent', 'treeRole'])
Expand All @@ -38,7 +39,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), ...planNotes(id)], 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 +57,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 delegation plan (#TBD) the Bot set, as one prompt line, so it sees its own split and
// the progress its children reported.
function planNotes(id) {
const line = planLine(fb.getField(id, PLAN_KEY))
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 +247,5 @@ 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 }
return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, refreshPrompt, parentOf, createChild, allocateChild, createTeam, findBots, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe }
}
Loading
Loading