feat(boss): Boss creates nests and assigns registry crows (#43) #52

Merged
jacisjake merged 2 commits from task/43-boss-nests into main 2026-10-28 15:20:29 +00:00
6 changed files with 274 additions and 8 deletions

No files matched your search

+2 -2
View File
@@ -117,7 +117,7 @@ A relay (D3, task #40): when the owner's request asks another named member crow
- The default nest is `todo`, with an empty core: its crows come from the whole flock, so nothing there is ever "borrowed".
- Crows are picked core-first (rotating within the core), then borrowed from outside it (rotating), still capped by the flock guardrails.
- Nests persist on the server (`.crows/nests.json`).
- Boss (Chief of Staff crow, CHATROOM_SPEC section 6) is a service role like Elder and Quill: a `request` addressed to it gets one `say` from Boss naming the crow and the outcome. Asked to spin up a crow, Boss answers with a structured order (`SPIN {"name","persona","trust"}`) that the harness executes; everything else gets a plain reply.
- Boss (Chief of Staff crow, CHATROOM_SPEC section 6) is a service role like Elder and Quill: a `request` addressed to it gets one `say` from Boss naming the crow and the outcome. Asked to spin up a crow, Boss answers with a structured order (`SPIN {"name","persona","trust"}`) that the harness executes; asked to create a nest, with a `NEST {"name","crows"}` order the harness executes through the nest store — so the harness may create nests via Boss, and a Boss-staffed nest may hold registry (spun) crows as members. No new HTTP endpoint. Anything else gets a plain reply.
### Spun crows
@@ -129,7 +129,7 @@ Crows the Boss creates live in a **crow registry** (`.crows/crows.json`), separa
| POST | `/api/nests` | `{"name": "Launch copy", "crows": ["Pepper", "Inky"]}` | 201 with the nest; 400 with `{"message"}` if invalid |
| POST | `/api/lists` | adds optional `"nest_id"` (default `todo`) | 404 with `{"message"}` for an unknown nest |
Guardrails: name 1–40 characters after trimming; `nest_id` is a slug of the name (lowercase, `a-z0-9-`), unique (409 with `{"message"}` on a clash); `crows` must be roster names, no duplicates, 1–8 of them; at most 20 nests.
Guardrails: name 1–40 characters after trimming; `nest_id` is a slug of the name (lowercase, `a-z0-9-`), unique (409 with `{"message"}` on a clash); `crows` must be roster or registry (spun) names, no duplicates, 1–8 of them; at most 20 nests.
Event changes:
- `list.started` adds `nest_id`.
+72 -3
View File
@@ -8,12 +8,16 @@ import (
)
// bossSystem is the Chief of Staff's contract. A spin-up order comes back on a
// SPIN line the harness reads and executes; anything else is plain talk.
// SPIN line and a nest order on a NEST line, both read and executed by the
// harness; anything else is plain talk.
const bossSystem = `You are Boss crow, the Chief of Staff of a flock of AI agents. You run the flock's schedule and keep its books.
When the owner asks you to spin up a new crow, your ENTIRE reply must start with this first line:
SPIN {"name":"<name>","persona":"<persona>","trust":"stranger|regular"}
Pick "persona" from the existing flock roster; a new crow only runs on an existing persona's habits. Starting trust is stranger or regular, never higher.
For anything else, reply in plain text. Do not use the SPIN line unless the owner asked for a new crow.`
When the owner asks you to create a nest, your ENTIRE reply must start with this first line:
NEST {"name":"<nest name>","crows":["<crow>","<crow>"]}
The crows are the working flock members the owner named; the Sentry, Elder, Quill and Boss serve every nest and are never members.
For anything else, reply in plain text. Do not use the SPIN or NEST line unless the owner asked for a new crow or a new nest.`
// spinOrder is the JSON after a "SPIN " prefix.
type spinOrder struct {
@@ -22,9 +26,16 @@ type spinOrder struct {
Trust string `json:"trust"`
}
// nestOrder is the JSON after a "NEST " prefix.
type nestOrder struct {
Name string `json:"name"`
Crows []string `json:"crows"`
}
// respondBoss answers a request addressed to Boss: one model call through the
// flock (CHATROOM_SPEC section 6). A SPIN reply is validated, written to the
// registry, and announced; a refusal says why. No reply posts nothing.
// registry, and announced; a NEST reply is validated and built through the
// nest store; a refusal says why. No reply posts nothing.
func (s *Server) respondBoss(nest Nest, req Message) {
budget := &Budget{name: "boss-command", limit: s.p.G.TokenBudgetPerItem}
system := systemFor("Boss", bossSystem, false)
@@ -34,11 +45,16 @@ func (s *Server) respondBoss(nest Nest, req Message) {
return // a flock that cannot answer says and does nothing
}
order, isSpin, reason := s.parseSpin(answer)
nOrder, isNest, nestReason := s.parseNest(answer)
switch {
case isSpin && reason == "":
s.spinCrow(nest, req, order)
case isSpin:
s.postBossSay(nest, req, order.Name, reason)
case isNest && nestReason == "":
s.createNest(nest, req, nOrder)
case isNest:
s.postBossSay(nest, req, nOrder.Name, nestReason)
default:
s.postBossSay(nest, req, "", strings.TrimSpace(answer))
}
@@ -82,6 +98,31 @@ func (s *Server) validPersona(name string) bool {
return ok
}
// parseNest reads a NEST reply. isNest says the reply was a nest order;
// reason, when not "", is why the order never reaches the nest store. Member
// names are resolved by the store itself: persona crows and registry crows
// pass, anything else (service roles included) is refused there.
func (s *Server) parseNest(answer string) (order nestOrder, isNest bool, reason string) {
text := strings.TrimSpace(answer)
if !strings.HasPrefix(text, "NEST ") {
return nestOrder{}, false, ""
}
if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(text, "NEST "))), &order); err != nil {
return nestOrder{}, true, "that order did not parse; try again."
}
order.Name = strings.TrimSpace(order.Name)
for i, c := range order.Crows {
order.Crows[i] = strings.TrimSpace(c)
}
switch {
case order.Name == "":
return order, true, "that order had no nest name in it."
case len(order.Crows) == 0:
return order, true, "that order had no crows in it."
}
return order, true, ""
}
// nameTaken checks the persona roster and the registry alike.
func (s *Server) nameTaken(name string) bool {
if s.validPersona(name) {
@@ -108,6 +149,34 @@ func (s *Server) spinCrow(nest Nest, req Message, order spinOrder) {
s.postBossSay(nest, req, order.Name, fmt.Sprintf("%s joined, running on %s's habits at %s.", order.Name, order.Persona, order.Trust))
}
// createNest builds the nest Boss was ordered into. The store has the last
// word on guardrails — name, members (persona roster or registry), duplicates
// and the nest cap are all checked there before anything mutates, so a bad
// order never leaves a partial nest. Success posts a Boss say announcing
// membership in the new nest, plus a confirming say in the requesting nest; a
// refusal says why.
func (s *Server) createNest(reqNest Nest, req Message, order nestOrder) {
created, err := s.nests.Create(order.Name, order.Crows)
if err != nil {
s.postBossSay(reqNest, req, order.Name, err.Error())
return
}
members := strings.Join(created.Crows, ", ")
open := Message{
NestID: created.ID,
Kind: "say",
Author: "Boss",
Text: fmt.Sprintf("This nest is open: %s. Its crows: %s.", created.Name, members),
To: req.Author,
TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if record, err := s.msgs.Append(open); err == nil {
s.emitNestMessage(record)
}
s.postBossSay(reqNest, req, created.Name, fmt.Sprintf("%s opened with %s.", created.Name, members))
}
// postBossSay is Boss's one-line reply: an outcome or a refusal, always a say.
func (s *Server) postBossSay(nest Nest, req Message, name, text string) {
reply := Message{
+171
View File
@@ -3,9 +3,11 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
@@ -333,3 +335,172 @@ func TestCrowsRegistryEmptyWithoutStore(t *testing.T) {
t.Fatalf("want no crows, got %v", out.Crows)
}
}
// --- #43: Boss creates nests and assigns members ---
// listNests returns every nest from GET /api/nests.
func listNests(t *testing.T, ts *httptest.Server) []map[string]any {
t.Helper()
resp, err := http.Get(ts.URL + "/api/nests")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /api/nests: want 200, got %d", resp.StatusCode)
}
var out struct {
Nests []map[string]any `json:"nests"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out.Nests
}
func findNest(t *testing.T, ts *httptest.Server, name string) map[string]any {
t.Helper()
for _, n := range listNests(t, ts) {
if n["name"] == name {
return n
}
}
return nil
}
func nestCrowNames(nest map[string]any) []string {
raw, _ := nest["crows"].([]any)
out := make([]string, 0, len(raw))
for _, c := range raw {
out = append(out, c.(string))
}
slices.Sort(out)
return out
}
func TestBossCreatesNest(t *testing.T) {
s, bb := newBossServer(t,
`SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`,
`NEST {"name":"Launch","crows":["Hugin","Ash"]}`,
)
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
// Ash must exist as a registry crow: spin it through Boss first.
askBoss(t, ts, labID, "spin up a crow named Ash")
bossSay(t, ts, labID, 2)
askBoss(t, ts, labID, "make a nest Launch with Hugin and Ash")
var launch map[string]any
deadline := time.Now().Add(2 * time.Second)
for launch == nil && time.Now().Before(deadline) {
launch = findNest(t, ts, "Launch")
if launch == nil {
time.Sleep(20 * time.Millisecond)
}
}
if launch == nil {
t.Fatalf("Boss must create the nest Launch, nests: %v", listNests(t, ts))
}
if got := nestCrowNames(launch); !slices.Equal(got, []string{"Ash", "Hugin"}) {
t.Fatalf("Launch crows must be exactly [Ash Hugin], got %v", got)
}
// Boss announces membership in the NEW nest: a say to the owner.
announced := false
for _, m := range awaitMessages(t, ts, launch["nest_id"].(string), 1) {
if m["author"] == "Boss" && m["kind"] == "say" && m["to"] == "owner" {
announced = true
}
}
if !announced {
t.Fatalf("Boss must post a say to the owner in Launch, got %v",
nestMessages(t, ts, launch["nest_id"].(string)))
}
// And confirms in the requesting nest.
confirmed := false
for _, m := range awaitMessages(t, ts, labID, 4) {
if m["author"] == "Boss" && m["kind"] == "say" && strings.Contains(m["text"].(string), "Launch") {
confirmed = true
}
}
if !confirmed {
t.Fatalf("Boss must confirm the new nest in Lab, got %v", nestMessages(t, ts, labID))
}
if bb.count() != 2 {
t.Fatalf("two orders are two model calls, got %d", bb.count())
}
}
func TestBossNestUnknownMemberRefused(t *testing.T) {
s, _ := newBossServer(t, `NEST {"name":"Nope","crows":["Gandalf"]}`)
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
before := len(listNests(t, ts))
askBoss(t, ts, labID, "make a nest Nope with Gandalf")
say := bossSay(t, ts, labID, 2)
text, _ := say["text"].(string)
if strings.HasPrefix(text, "NEST") {
t.Fatalf("Boss must interpret the order, not echo it back: %q", text)
}
if !strings.Contains(text, "Gandalf") {
t.Fatalf("the refusal must name the unknown member, got %q", text)
}
if after := len(listNests(t, ts)); after != before {
t.Fatalf("an unknown member must refuse the whole create: %d before, %d after", before, after)
}
}
func TestBossNestCapRespected(t *testing.T) {
s, _ := newBossServer(t, `NEST {"name":"Overflow","crows":["Hugin"]}`)
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
// Fill the store to its cap: todo + Lab + (maxNests-2) pads.
for i := 0; 2+i < maxNests; i++ {
if _, err := s.nests.Create(fmt.Sprintf("Pad %d", i), []string{"Hugin"}); err != nil {
t.Fatalf("padding nests: %v", err)
}
}
if n := len(listNests(t, ts)); n != maxNests {
t.Fatalf("test setup: want %d nests, got %d", maxNests, n)
}
askBoss(t, ts, labID, "make a nest Overflow with Hugin") // 201: no 500 at the front door
say := bossSay(t, ts, labID, 2)
text, _ := say["text"].(string)
if !strings.Contains(text, fmt.Sprintf("at most %d nests", maxNests)) {
t.Fatalf("Boss must report the store's cap refusal, got %q", text)
}
if findNest(t, ts, "Overflow") != nil {
t.Fatal("a capped create must not make a nest")
}
}
func TestBossNestGoesThroughFlock(t *testing.T) {
s, _ := newBossServer(t, `NEST {"name":"Launch","crows":["Hugin","Munin"]}`)
s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
before := len(listNests(t, ts))
askBoss(t, ts, labID, "make a nest Launch with Hugin and Munin")
time.Sleep(300 * time.Millisecond)
if n := len(listNests(t, ts)); n != before {
t.Fatalf("a saturated flock must not create a nest, got %v", listNests(t, ts))
}
for _, m := range nestMessages(t, ts, labID) {
if m["author"] == "Boss" {
t.Fatalf("a saturated flock must not produce a Boss message, got %v", m)
}
}
}
+6
View File
@@ -86,6 +86,12 @@ func (s *CrowStore) Add(c SpunCrow) error {
return nil
}
// Has reports whether a crow with that name is in the registry.
func (s *CrowStore) Has(name string) bool {
_, ok := s.Get(name)
return ok
}
// Len is how many crows the registry holds.
func (s *CrowStore) Len() int {
s.mu.Lock()
+14 -1
View File
@@ -47,6 +47,19 @@ type NestStore struct {
path string
mu sync.Mutex
made []Nest // created nests, in creation order; the default nest is not stored
allowCrow func(name string) bool // extra member names beyond the roster (spun crows)
}
// AllowCrows extends the names that may be nest members beyond the persona
// roster: the server wires it to the spun-crow registry so the Boss can
// staff a nest with registry crows (#43). It is set once at wiring time,
// before anything is served; Create stays the only mutation.
func (s *NestStore) AllowCrows(ok func(name string) bool) { s.allowCrow = ok }
// memberKnown reports whether a name may join a nest: a working persona crow
// (the roster excludes the service roles) or an allowed extra name.
func (s *NestStore) memberKnown(name string) bool {
return slices.Contains(roster, name) || (s.allowCrow != nil && s.allowCrow(name))
}
func LoadNests(path string) (*NestStore, error) {
@@ -104,7 +117,7 @@ func (s *NestStore) Create(name string, crows []string) (Nest, error) {
return Nest{}, invalid("A nest has 1 to %d crows.", len(roster))
}
for i, c := range crows {
if !slices.Contains(roster, c) {
if !s.memberKnown(c) {
return Nest{}, invalid("%q is not in the flock. Choose from %s.", c, strings.Join(roster, ", "))
}
if slices.Contains(crows[:i], c) {
+9 -2
View File
@@ -46,8 +46,15 @@ func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *N
// WithMessages attaches the nest conversation log (chatroom path).
func (s *Server) WithMessages(m *MessageStore) *Server { s.msgs = m; return s }
// WithCrows attaches the spun-crow registry (the Boss writes through it).
func (s *Server) WithCrows(c *CrowStore) *Server { s.crows = c; return s }
// WithCrows attaches the spun-crow registry (the Boss writes through it) and
// lets registry crows be nest members (#43).
func (s *Server) WithCrows(c *CrowStore) *Server {
s.crows = c
if c != nil && s.nests != nil {
s.nests.AllowCrows(c.Has)
}
return s
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()