(#24) Conspiracy invites + lens directives #31
No files matched your search
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordBrain stores every (system,prompt) it is asked; replies as a claim.
|
||||||
|
type recordBrain struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pairs [][2]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *recordBrain) Name() string { return "recorder" }
|
||||||
|
func (b *recordBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.pairs = append(b.pairs, [2]string{system, prompt})
|
||||||
|
b.mu.Unlock()
|
||||||
|
return "KIND: claim\nOn it.", 10, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecordServer(t *testing.T) (*httptest.Server, *Server, *recordBrain) {
|
||||||
|
t.Helper()
|
||||||
|
ts, s := newTestNestServer(t)
|
||||||
|
rb := &recordBrain{}
|
||||||
|
s.p.Flock.brain = rb
|
||||||
|
return ts, s, rb
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConspiracyInvitesDistinctLenses(t *testing.T) {
|
||||||
|
ts, _, rb := newRecordServer(t)
|
||||||
|
id := newNest(t, ts, "Lab", `"Hugin","Munin","Soot"`)
|
||||||
|
code, req := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","mode":"conspiracy","text":"is this launch risky"}`)
|
||||||
|
if code != http.StatusCreated || req["mode"] != "conspiracy" {
|
||||||
|
t.Fatalf("post: %d %v", code, req)
|
||||||
|
}
|
||||||
|
awaitMessages(t, ts, id, 4) // request + 3 lens claims
|
||||||
|
|
||||||
|
rb.mu.Lock()
|
||||||
|
defer rb.mu.Unlock()
|
||||||
|
if len(rb.pairs) != 3 {
|
||||||
|
t.Fatalf("want exactly 3 invited prompts, got %d", len(rb.pairs))
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, pr := range rb.pairs {
|
||||||
|
if !strings.Contains(pr[1], conspiracyLine) {
|
||||||
|
t.Fatalf("every invited prompt must carry the lens line: %q", pr[1])
|
||||||
|
}
|
||||||
|
hits := 0
|
||||||
|
for _, name := range []string{"Hugin", "Munin", "Soot"} {
|
||||||
|
if strings.Contains(pr[1], "("+name+")") {
|
||||||
|
hits++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits != 1 {
|
||||||
|
t.Fatalf("prompt should name exactly one crow's lens, got %d in %q", hits, pr[1])
|
||||||
|
}
|
||||||
|
if seen[pr[1]] {
|
||||||
|
t.Fatal("two invited prompts were identical")
|
||||||
|
}
|
||||||
|
seen[pr[1]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConspiracyInviteCapped(t *testing.T) {
|
||||||
|
ts, _, rb := newRecordServer(t)
|
||||||
|
id := newNest(t, ts, "Wide", strings.Join(quoteAll(roster), ","))
|
||||||
|
code, out := post(t, ts.URL+"/api/nests/"+id+"/messages",
|
||||||
|
`{"author":"owner","kind":"request","mode":"conspiracy","invited":4,"text":"risk"}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("post: %d %v", code, out)
|
||||||
|
}
|
||||||
|
awaitMessages(t, ts, id, 4)
|
||||||
|
rb.mu.Lock()
|
||||||
|
n := len(rb.pairs)
|
||||||
|
rb.mu.Unlock()
|
||||||
|
if n != 3 { // min(invited 4, MurderSize 3)
|
||||||
|
t.Fatalf("want invite capped to MurderSize=3, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteAll(xs []string) []string {
|
||||||
|
out := make([]string, len(xs))
|
||||||
|
for i, x := range xs {
|
||||||
|
out[i] = `"` + x + `"`
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+61
-9
@@ -82,6 +82,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
|
Invited int `json:"invited"`
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
RefersTo string `json:"refers_to"`
|
RefersTo string `json:"refers_to"`
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,13 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Mode (Epic C, same rule shape as kind): Jev first, header escape, "" when unsure.
|
// Mode (Epic C, same rule shape as kind): Jev first, header escape, "" when unsure.
|
||||||
mode, modeBy := s.tagMode(kind, body.Mode, body.Text)
|
mode, modeBy := s.tagMode(kind, body.Mode, body.Text)
|
||||||
|
|
||||||
|
// A conspiracy expects several crows on the one task; the invite count is
|
||||||
|
// capped by MurderSize and the flock's in-flight limit.
|
||||||
|
invited := 0
|
||||||
|
if mode == "conspiracy" {
|
||||||
|
invited = s.inviteCount(nest, body.Invited)
|
||||||
|
}
|
||||||
|
|
||||||
// Claim arbitration (#5, Epic C): first claim wins unless the referent is a
|
// Claim arbitration (#5, Epic C): first claim wins unless the referent is a
|
||||||
// conspiracy, which expects multiple claims.
|
// conspiracy, which expects multiple claims.
|
||||||
if kind == "claim" && body.RefersTo != "" {
|
if kind == "claim" && body.RefersTo != "" {
|
||||||
@@ -153,6 +161,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
TaggedBy: taggedBy,
|
TaggedBy: taggedBy,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
ModeTagged: modeBy,
|
ModeTagged: modeBy,
|
||||||
|
Invited: invited,
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
record, err := s.msgs.Append(msg)
|
record, err := s.msgs.Append(msg)
|
||||||
@@ -163,11 +172,17 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
s.emitNestMessage(record)
|
s.emitNestMessage(record)
|
||||||
|
|
||||||
// Event-driven trigger: a request addressed to a member crow gets one
|
// Event-driven trigger (no idle loops):
|
||||||
// response (must-respond) or a Sentry flag.
|
// conspiracy -> several invited crows, each from its own lens
|
||||||
if kind == "request" && slices.Contains(nest.Crows, record.To) {
|
// any other request addressed to a member crow -> one must-respond reply
|
||||||
|
if kind == "request" {
|
||||||
|
switch {
|
||||||
|
case mode == "conspiracy" && invited > 0:
|
||||||
|
go s.respondConspiracy(nest, record, invited)
|
||||||
|
case slices.Contains(nest.Crows, record.To):
|
||||||
go s.respondToRequest(nest, record)
|
go s.respondToRequest(nest, record)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, nestRecord(record))
|
writeJSON(w, http.StatusCreated, nestRecord(record))
|
||||||
}
|
}
|
||||||
@@ -215,15 +230,52 @@ func (s *Server) emitNestMessage(m Message) {
|
|||||||
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondToRequest is the must-respond rule: the addressed crow answers with
|
// inviteCount is how many crows a conspiracy asks to take the one task, capped
|
||||||
// claim, deliver or decline. A reply that isn't one of those is retried once
|
// by MurderSize, the flock's in-flight limit, and the nest's own size.
|
||||||
// with a stricter ask; still nothing responsive and the crow gets a flag from
|
func (s *Server) inviteCount(nest Nest, requested int) int {
|
||||||
// the Sentry (no crow reply is posted).
|
cap := s.p.G.MurderSize
|
||||||
|
if s.p.G.MaxCrowsInFlight < cap {
|
||||||
|
cap = s.p.G.MaxCrowsInFlight
|
||||||
|
}
|
||||||
|
if requested > 0 && requested < cap {
|
||||||
|
cap = requested
|
||||||
|
}
|
||||||
|
if len(nest.Crows) < cap {
|
||||||
|
cap = len(nest.Crows)
|
||||||
|
}
|
||||||
|
return cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondConspiracy invites N crows; each answers from its own persona lens so
|
||||||
|
// distinct strengths produce distinct readings.
|
||||||
|
func (s *Server) respondConspiracy(nest Nest, req Message, invited int) {
|
||||||
|
crows := s.p.Flock.pick(nest, invited)
|
||||||
|
for _, crow := range crows {
|
||||||
|
go s.respondOne(nest, req, crow, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// conspiracyLine is the lens directive added to every invited crow's prompt.
|
||||||
|
const conspiracyLine = "You are one lens among several on this request; answer from your own strength, not the flock's consensus."
|
||||||
|
|
||||||
func (s *Server) respondToRequest(nest Nest, req Message) {
|
func (s *Server) respondToRequest(nest Nest, req Message) {
|
||||||
crow := req.To
|
s.respondOne(nest, req, req.To, false)
|
||||||
budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem}
|
}
|
||||||
|
|
||||||
|
// respondOne is the must-respond rule for a single crow: answer with claim,
|
||||||
|
// deliver or decline. A non-responsive reply is retried once with a stricter
|
||||||
|
// ask; still nothing responsive and the crow gets a Sentry flag (no reply).
|
||||||
|
func (s *Server) respondOne(nest Nest, req Message, crow string, lens bool) {
|
||||||
|
budget := &Budget{name: "nest-reply", limit: s.p.G.TokenBudgetPerItem}
|
||||||
system := systemFor(crow, nestReplySystem, false)
|
system := systemFor(crow, nestReplySystem, false)
|
||||||
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nRespond to " + req.Author + ".\nTASK: " + req.Text
|
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nRespond to " + req.Author + ".\nTASK: " + req.Text
|
||||||
|
if lens {
|
||||||
|
strength := ""
|
||||||
|
if p, ok := personaOf(crow); ok {
|
||||||
|
strength = p.Strength
|
||||||
|
}
|
||||||
|
prompt += "\n" + conspiracyLine + "\nYour strength: " + strength + " (" + crow + ")."
|
||||||
|
}
|
||||||
|
|
||||||
kind, body, ok := s.nestReply(system, prompt, crow, budget)
|
kind, body, ok := s.nestReply(system, prompt, crow, budget)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
Reference in new issue
Block a user