(#11) @-addressing parse + must-respond #18

Merged
jacisjake merged 2 commits from task/11-addressing-must-respond into main 2026-10-28 10:54:20 +00:00
5 changed files with 143 additions and 21 deletions
Showing only changes of commit 4cfe9df6ba - Show all commits

No files matched your search

+2 -1
View File
@@ -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. | | `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. | | `deliver` | a crow | The result, addressed back to whoever requested. |
| `refer` | any member | Points to an artifact by name instead of pasting it. | | `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. | | `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`. | | `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). - `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). - `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`. - Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`.
## Settled details ## Settled details
+41
View File
@@ -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
}
+2
View File
@@ -233,6 +233,8 @@ func (MockBrain) Ask(ctx context.Context, system, prompt string) (string, int, e
switch { switch {
case strings.Contains(system, markerBanter): case strings.Contains(system, markerBanter):
return "[mock] Caw.", tokens, nil 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): case strings.Contains(system, markerSentry):
return mockTriage(after(prompt, "ITEM:")), tokens, nil return mockTriage(after(prompt, "ITEM:")), tokens, nil
case strings.Contains(system, markerElder): case strings.Contains(system, markerElder):
+84 -20
View File
@@ -15,6 +15,22 @@ var crowPostable = map[string]bool{
"claim": true, "claim": true,
"deliver": true, "deliver": true,
"refer": 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) { 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.Text = strings.TrimSpace(body.Text)
body.To = strings.TrimSpace(body.To) body.To = strings.TrimSpace(body.To)
if body.To == "" {
body.To = parseAddressing(body.Text, nestMembers(nest))
}
// Guardrails (EVENTS.md: Nest messages → Guardrails) // Guardrails (EVENTS.md: Nest messages → Guardrails)
if n := utf8.RuneCountInString(body.Text); n < 1 || n > s.p.G.MaxItemChars { 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. // Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
kind, taggedBy := s.tagKind(body.Kind, body.Text) 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{ msg := Message{
NestID: nestID, NestID: nestID,
Kind: kind, Kind: kind,
@@ -106,9 +133,10 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
s.emitNestMessage(record) s.emitNestMessage(record)
// Event-driven trigger: if kind is request and To is a crow, respond. // Event-driven trigger: a request addressed to a member crow gets one
if kind == "request" && record.To != "" && record.To != "owner" { // response (must-respond) or a Sentry flag.
go s.crowResponds(nest, record) if kind == "request" && slices.Contains(nest.Crows, record.To) {
go s.respondToRequest(nest, record)
} }
writeJSON(w, http.StatusCreated, map[string]any{ 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) { // respondToRequest is the must-respond rule: the addressed crow answers with
crows := s.p.Flock.pick(nest, 1) // claim, deliver or decline. A reply that isn't one of those is retried once
if len(crows) == 0 { // with a stricter ask; still nothing responsive and the crow gets a flag from
return // the Sentry (no crow reply is posted).
} func (s *Server) respondToRequest(nest Nest, req Message) {
crow := crows[0] // B4 (@-addressing) makes this the addressed crow crow := req.To
system := systemFor(crow, crowSystem, true)
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nTEXT: " + req.Text
budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem} budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem}
answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget) system := systemFor(crow, nestReplySystem, false)
if err != nil { 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 return
} }
reply := Message{ reply := Message{
NestID: nest.ID, NestID: nest.ID,
Kind: "say", Kind: kind,
Author: crow, Author: crow,
Text: strings.TrimSpace(answer), Text: body,
To: req.Author, 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(), RefersTo: req.MsgID(),
TaggedBy: "harness", TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339), Timestamp: time.Now().UTC().Format(time.RFC3339),
} }
record, err := s.msgs.Append(reply) if record, err := s.msgs.Append(flag); err == nil {
if err != nil {
return
}
s.emitNestMessage(record) s.emitNestMessage(record)
}
} }
+14
View File
@@ -102,6 +102,20 @@ func (s *MessageStore) After(nestID string, lastID int) []Message {
// All returns every message in a nest, oldest first. // All returns every message in a nest, oldest first.
func (s *MessageStore) All(nestID string) []Message { return s.After(nestID, 0) } func (s *MessageStore) All(nestID string) []Message { return s.After(nestID, 0) }
// ByMsgID finds one stored message by its "<nest_id>-<n>" 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 { func (s *MessageStore) save(nestID string) error {
if err := os.MkdirAll(s.dir, 0o755); err != nil { if err := os.MkdirAll(s.dir, 0o755); err != nil {
return err return err