diff --git a/EVENTS.md b/EVENTS.md index e478ad6..2159c31 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -41,6 +41,7 @@ A nest is a conversation. Members (the owner and crows) post messages of a **kin | `claim` | a crow | Takes ownership of a `request`. solo/split: first claim wins. **conspiracy:** multiple claims are expected, each a distinct lens. | | `deliver` | a crow | The result, addressed back to whoever requested. | | `refer` | any member | Points to an artifact by name instead of pasting it. | +| `decline` | a crow | Refuses a request with a reason. Counts as responding. | | `nudge` | harness only | A Jev verdict handed to one crow to proceed on a missed expectation. Not a chat message from a crow. | | `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`. | @@ -81,7 +82,7 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev - `text` 1–2,000 characters after trimming (same cap as a list item). - `author` must be `"owner"` or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary). -- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`). Crows may not post `nudge` or `flag`. +- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`, `decline`). Crows may not post `nudge` or `flag`. - Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`. ## Settled details diff --git a/addressing.go b/addressing.go new file mode 100644 index 0000000..e483ba8 --- /dev/null +++ b/addressing.go @@ -0,0 +1,41 @@ +package main + +import ( + "strings" + "unicode" +) + +// responsive kinds: how an addressed crow may answer a request (must-respond). +var responsiveKind = map[string]bool{"claim": true, "deliver": true, "decline": true} + +// parseAddressing finds the first @handle in text that names one of members. +// An unknown handle stays literal text and yields "" (no address). +func parseAddressing(text string, members []string) string { + for _, tok := range strings.Fields(text) { + if !strings.HasPrefix(tok, "@") { + continue + } + name := strings.TrimFunc(tok[1:], func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) + for _, m := range members { + if strings.EqualFold(name, m) { + return m + } + } + } + return "" +} + +// splitKind pulls a leading "KIND: x" line off a crow's raw reply and returns +// the lowercased kind word ("" if absent) and the remaining body. +func splitKind(text string) (kind, body string) { + trimmed := strings.TrimLeft(text, " \t\n") + i := strings.IndexByte(trimmed, '\n') + first, rest := trimmed, "" + if i >= 0 { + first, rest = trimmed[:i], strings.TrimSpace(trimmed[i+1:]) + } + if len(first) >= 5 && strings.EqualFold(first[:5], "KIND:") { + return strings.ToLower(strings.TrimSpace(first[5:])), rest + } + return "", text +} diff --git a/brain.go b/brain.go index 0239ab3..8053e70 100644 --- a/brain.go +++ b/brain.go @@ -233,6 +233,8 @@ func (MockBrain) Ask(ctx context.Context, system, prompt string) (string, int, e switch { case strings.Contains(system, markerBanter): return "[mock] Caw.", tokens, nil + case strings.Contains(system, markerNestReply): + return "KIND: deliver\n[mock] handled: " + after(prompt, "TASK:"), tokens, nil case strings.Contains(system, markerSentry): return mockTriage(after(prompt, "ITEM:")), tokens, nil case strings.Contains(system, markerElder): diff --git a/nestapi.go b/nestapi.go index 07658cd..d1a8b22 100644 --- a/nestapi.go +++ b/nestapi.go @@ -15,6 +15,22 @@ var crowPostable = map[string]bool{ "claim": true, "deliver": true, "refer": true, + "decline": true, +} + +// nestReplySystem tells an addressed crow the response contract: its reply +// must open with a KIND line the harness reads (must-respond). +const markerNestReply = "This is a chatroom request." +const nestReplySystem = crowSystem + ` +` + markerNestReply + ` Start your reply on its own first line with exactly: +KIND: claim (you will do it) +KIND: deliver (here is the result) +KIND: decline (with a reason) +Then, on the next line, your message text.` + +// nestMembers lists who may be addressed in a nest: its crows plus the owner. +func nestMembers(nest Nest) []string { + return append(append([]string{}, nest.Crows...), "owner") } func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) { @@ -61,6 +77,9 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { body.Text = strings.TrimSpace(body.Text) body.To = strings.TrimSpace(body.To) + if body.To == "" { + body.To = parseAddressing(body.Text, nestMembers(nest)) + } // Guardrails (EVENTS.md: Nest messages → Guardrails) if n := utf8.RuneCountInString(body.Text); n < 1 || n > s.p.G.MaxItemChars { @@ -88,6 +107,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { // Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort. kind, taggedBy := s.tagKind(body.Kind, body.Text) + // claim/deliver/decline answer a request: with no explicit address they go + // back to whoever posted the message they refer to (EVENTS.md deliver rule). + if body.To == "" && (kind == "claim" || kind == "deliver" || kind == "decline") && body.RefersTo != "" { + if origin, found := s.msgs.ByMsgID(body.RefersTo); found { + body.To = origin.Author + } + } + msg := Message{ NestID: nestID, Kind: kind, @@ -106,9 +133,10 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { s.emitNestMessage(record) - // Event-driven trigger: if kind is request and To is a crow, respond. - if kind == "request" && record.To != "" && record.To != "owner" { - go s.crowResponds(nest, record) + // Event-driven trigger: a request addressed to a member crow gets one + // response (must-respond) or a Sentry flag. + if kind == "request" && slices.Contains(nest.Crows, record.To) { + go s.respondToRequest(nest, record) } writeJSON(w, http.StatusCreated, map[string]any{ @@ -153,32 +181,68 @@ func (s *Server) emitNestMessage(m Message) { }) } -func (s *Server) crowResponds(nest Nest, req Message) { - crows := s.p.Flock.pick(nest, 1) - if len(crows) == 0 { - return - } - crow := crows[0] // B4 (@-addressing) makes this the addressed crow - system := systemFor(crow, crowSystem, true) - prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nTEXT: " + req.Text +// respondToRequest is the must-respond rule: the addressed crow answers with +// claim, deliver or decline. A reply that isn't one of those is retried once +// with a stricter ask; still nothing responsive and the crow gets a flag from +// the Sentry (no crow reply is posted). +func (s *Server) respondToRequest(nest Nest, req Message) { + crow := req.To budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem} - answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget) - if err != nil { + system := systemFor(crow, nestReplySystem, false) + prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nTASK: " + req.Text + "\nRespond to " + req.Author + "." + + kind, body, ok := s.nestReply(system, prompt, crow, budget) + if !ok { + nudge := prompt + "\nYour last reply did not claim, deliver or decline this request. Answer with a KIND line now." + kind, body, ok = s.nestReply(system, nudge, crow, budget) + } + if !ok { + s.postFlag(nest, crow, req) return } reply := Message{ NestID: nest.ID, - Kind: "say", + Kind: kind, Author: crow, - Text: strings.TrimSpace(answer), - To: req.Author, + Text: body, + To: req.Author, // responses go back to whoever requested + RefersTo: req.MsgID(), + TaggedBy: "header", + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + if record, err := s.msgs.Append(reply); err == nil { + s.emitNestMessage(record) + } +} + +// nestReply asks one crow once and reads its KIND header. ok means the reply +// was responsive (claim, deliver or decline). +func (s *Server) nestReply(system, prompt, crow string, budget *Budget) (kind, body string, ok bool) { + answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget) + if err != nil { + return "", "", false + } + kind, body = splitKind(answer) + if !responsiveKind[kind] || strings.TrimSpace(body) == "" { + return "", "", false + } + return kind, strings.TrimSpace(body), true +} + +// postFlag records the Sentry's breach note: an addressed crow that would not +// answer. The quarantine/ask_owner loop lands with the watchdog epic. +func (s *Server) postFlag(nest Nest, crow string, req Message) { + flag := Message{ + NestID: nest.ID, + Kind: "flag", + Author: "Sentry", + Text: fmt.Sprintf("%s ignored a request from %s and did not answer after one retry. Flagged for contract breach.", crow, req.Author), + To: crow, RefersTo: req.MsgID(), TaggedBy: "harness", Timestamp: time.Now().UTC().Format(time.RFC3339), } - record, err := s.msgs.Append(reply) - if err != nil { - return + if record, err := s.msgs.Append(flag); err == nil { + s.emitNestMessage(record) } - s.emitNestMessage(record) } diff --git a/nestapi_test.go b/nestapi_test.go index acef3f4..fd9d720 100644 --- a/nestapi_test.go +++ b/nestapi_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -9,7 +10,6 @@ import ( "time" ) -// newTestNestServer is newTestServer plus a message store. func newTestNestServer(t *testing.T) (*httptest.Server, *Server) { t.Helper() ts, s := newTestServer(t, DefaultGuardrails(), time.Minute) @@ -21,6 +21,34 @@ func newTestNestServer(t *testing.T) (*httptest.Server, *Server) { return ts, s } +// scriptedBrain answers nest requests with a canned reply per call. +type scriptedBrain struct { + replies []string + calls int +} + +func (b *scriptedBrain) Name() string { return "scripted" } +func (b *scriptedBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + r := "[mock] fallback" + if b.calls < len(b.replies) { + r = b.replies[b.calls] + } + b.calls++ + return r, 10, nil +} + +func newScriptedServer(t *testing.T, replies ...string) (*httptest.Server, *Server) { + t.Helper() + ts, s := newTestNestServer(t) + s.p.Flock.brain = &scriptedBrain{replies: replies} + return ts, s +} + +func nestOf(s *Server, id string) Nest { + n, _ := s.nests.Get(id) + return n +} + func newNest(t *testing.T, ts *httptest.Server, name, crows string) string { t.Helper() code, out := post(t, ts.URL+"/api/nests", `{"name":"`+name+`","crows":[`+crows+`]}`) @@ -99,8 +127,8 @@ func TestPostMessageTriggersOneResponse(t *testing.T) { for _, m := range msgs { if m["author"] == "Hugin" { replies++ - if m["kind"] != "say" && m["kind"] != "claim" && m["kind"] != "deliver" { - t.Fatalf("crow reply has a bad kind: %v", m["kind"]) + if m["kind"] != "claim" && m["kind"] != "deliver" && m["kind"] != "decline" { + t.Fatalf("crow reply must be a responsive kind, got %v", m["kind"]) } if m["refers_to"] != req["msg_id"] { t.Fatalf("crow reply must point at the request, got %v", m["refers_to"]) @@ -124,3 +152,57 @@ func TestNoIdleResponse(t *testing.T) { t.Fatalf("an unaddressed say must not summon a crow, log: %v", got) } } + +func TestParseAddressing(t *testing.T) { + members := []string{"Hugin", "Munin", "owner"} + if got := parseAddressing("Taking it, @Hugin", members); got != "Hugin" { + t.Fatalf("want Hugin, got %q", got) + } + if got := parseAddressing("hello @Nobody", members); got != "" { + t.Fatalf("unknown handle must not resolve, got %q", got) + } + if got := parseAddressing("ask @Munin.", members); got != "Munin" { + t.Fatalf("want trailing period stripped, got %q", got) + } +} + +func TestAddressedCrowMustRespond(t *testing.T) { + ts, s := newScriptedServer(t, "some idle chatter", "again not a reply") + id := newNest(t, ts, "Lab", `"Hugin"`) + s.msgs.Append(Message{NestID: id, Kind: "request", Author: "owner", To: "Hugin", Text: "count the eggs"}) + req := s.msgs.All(id)[0] + s.respondToRequest(nestOf(s, id), req) + msgs := s.msgs.All(id) + if len(msgs) != 2 || msgs[1].Kind != "flag" || msgs[1].Author != "Sentry" || msgs[1].To != "Hugin" { + t.Fatalf("two non-responsive replies must end in a Sentry flag, got %+v", msgs) + } + if msgs[1].RefersTo != req.MsgID() { + t.Fatalf("flag must point at the ignored request, got %q", msgs[1].RefersTo) + } +} + +func TestRespondedRequestNoFlag(t *testing.T) { + ts, s := newScriptedServer(t, "KIND: claim\nOn it, @owner.") + id := newNest(t, ts, "Lab", `"Hugin"`) + s.msgs.Append(Message{NestID: id, Kind: "request", Author: "owner", To: "Hugin", Text: "count the eggs"}) + req := s.msgs.All(id)[0] + s.respondToRequest(nestOf(s, id), req) + msgs := s.msgs.All(id) + if len(msgs) != 2 || msgs[1].Kind != "claim" || msgs[1].Text != "On it, @owner." { + t.Fatalf("a responsive first reply must post the claim, got %+v", msgs) + } +} + +func TestDeliverAddressesRequester(t *testing.T) { + ts, _ := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"request","text":"count the eggs","to":"Munin"}`) + code, d := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"Munin","kind":"deliver","text":"three eggs","refers_to":"`+req["msg_id"].(string)+`"}`) + if code != http.StatusCreated { + t.Fatalf("deliver: %d %v", code, d) + } + if d["to"] != "owner" { + t.Fatalf("deliver must go back to the requester, got to=%v", d["to"]) + } +} diff --git a/nestlog.go b/nestlog.go index 17f4bd1..ec892b4 100644 --- a/nestlog.go +++ b/nestlog.go @@ -102,6 +102,20 @@ func (s *MessageStore) After(nestID string, lastID int) []Message { // All returns every message in a nest, oldest first. func (s *MessageStore) All(nestID string) []Message { return s.After(nestID, 0) } +// ByMsgID finds one stored message by its "-" id. +func (s *MessageStore) ByMsgID(msgID string) (Message, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, msgs := range s.log { + for _, m := range msgs { + if m.MsgID() == msgID { + return m, true + } + } + } + return Message{}, false +} + func (s *MessageStore) save(nestID string) error { if err := os.MkdirAll(s.dir, 0o755); err != nil { return err