(#11) @-addressing parse + must-respond #18
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+85
-3
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -9,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestNestServer is newTestServer plus a message store.
|
|
||||||
func newTestNestServer(t *testing.T) (*httptest.Server, *Server) {
|
func newTestNestServer(t *testing.T) (*httptest.Server, *Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ts, s := newTestServer(t, DefaultGuardrails(), time.Minute)
|
ts, s := newTestServer(t, DefaultGuardrails(), time.Minute)
|
||||||
@@ -21,6 +21,34 @@ func newTestNestServer(t *testing.T) (*httptest.Server, *Server) {
|
|||||||
return ts, s
|
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 {
|
func newNest(t *testing.T, ts *httptest.Server, name, crows string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
code, out := post(t, ts.URL+"/api/nests", `{"name":"`+name+`","crows":[`+crows+`]}`)
|
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 {
|
for _, m := range msgs {
|
||||||
if m["author"] == "Hugin" {
|
if m["author"] == "Hugin" {
|
||||||
replies++
|
replies++
|
||||||
if m["kind"] != "say" && m["kind"] != "claim" && m["kind"] != "deliver" {
|
if m["kind"] != "claim" && m["kind"] != "deliver" && m["kind"] != "decline" {
|
||||||
t.Fatalf("crow reply has a bad kind: %v", m["kind"])
|
t.Fatalf("crow reply must be a responsive kind, got %v", m["kind"])
|
||||||
}
|
}
|
||||||
if m["refers_to"] != req["msg_id"] {
|
if m["refers_to"] != req["msg_id"] {
|
||||||
t.Fatalf("crow reply must point at the request, got %v", m["refers_to"])
|
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)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user