606 lines
20 KiB
Go
606 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var crowPostable = map[string]bool{
|
|
"say": true,
|
|
"request": true,
|
|
"claim": true,
|
|
"deliver": true,
|
|
"refer": true,
|
|
"decline": true,
|
|
}
|
|
|
|
// kindAskOwner is a decision only the owner can make (#41). It is postable by
|
|
// the owner-facing service roles and the owner, never by an ordinary crow: a
|
|
// crow's ask_owner header is dropped and the message tags as it otherwise
|
|
// would be. Jev's kind question never proposes it; the header bypasses
|
|
// tagging entirely, like nudge and flag.
|
|
const kindAskOwner = "ask_owner"
|
|
|
|
var ownerFacingRoles = map[string]bool{"Quill": true, "Sentry": 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.`
|
|
|
|
// serviceRoles are addressable in a nest but never members: they only act when
|
|
// a crow's or the owner's message asks them to.
|
|
var serviceRoles = []string{"Elder", "Quill", "Boss"}
|
|
|
|
// nestMembers lists who may be addressed in a nest: its crows, the owner, and
|
|
// the service roles.
|
|
func nestMembers(nest Nest) []string {
|
|
return append(append(append([]string{}, nest.Crows...), "owner"), serviceRoles...)
|
|
}
|
|
|
|
func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) {
|
|
nestID := r.PathValue("nest_id")
|
|
if _, found := s.nests.Get(nestID); !found {
|
|
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
|
|
return
|
|
}
|
|
all := s.msgs.All(nestID)
|
|
out := make([]map[string]any, 0, len(all))
|
|
for _, m := range all {
|
|
out = append(out, s.nestRecord(m))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// nestRecord is the EVENTS.md wire shape for one message. resolved is computed
|
|
// at read time: an ask_owner approves only on the owner's exact phrase and
|
|
// rejects on the fail-safe timeout; every other message reads "".
|
|
func (s *Server) nestRecord(m Message) map[string]any {
|
|
rec := map[string]any{
|
|
"msg_id": m.MsgID(),
|
|
"kind": m.Kind,
|
|
"author": m.Author,
|
|
"text": m.Text,
|
|
"to": m.To,
|
|
"refers_to": m.RefersTo,
|
|
"tagged_by": m.TaggedBy,
|
|
"approval_phrase": m.ApprovalPhrase,
|
|
"resolved": s.askResolved(m),
|
|
"ts": m.Timestamp,
|
|
}
|
|
if m.Mode != "" {
|
|
rec["mode"] = m.Mode
|
|
rec["mode_tagged_by"] = m.ModeTagged
|
|
}
|
|
if m.Invited > 0 {
|
|
rec["invited"] = m.Invited
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// askResolved computes an ask_owner's state: approved only when the owner
|
|
// replied to it with the exact approval phrase (trimmed and lowercased on both
|
|
// sides); rejected when nothing answered it before the timeout; "" while open.
|
|
// Anything but an explicit yes is a no.
|
|
func (s *Server) askResolved(m Message) string {
|
|
if m.Kind != kindAskOwner {
|
|
return ""
|
|
}
|
|
phrase := strings.ToLower(strings.TrimSpace(m.ApprovalPhrase))
|
|
if phrase != "" {
|
|
for _, r := range s.msgs.All(m.NestID) {
|
|
if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" &&
|
|
strings.ToLower(strings.TrimSpace(r.Text)) == phrase {
|
|
return "approved"
|
|
}
|
|
}
|
|
}
|
|
if ts, err := time.Parse(time.RFC3339, m.Timestamp); err == nil && time.Since(ts) > s.askTimeout {
|
|
return "rejected"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|
nestID := r.PathValue("nest_id")
|
|
nest, found := s.nests.Get(nestID)
|
|
if !found {
|
|
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Author string `json:"author"`
|
|
Text string `json:"text"`
|
|
Kind string `json:"kind"`
|
|
Mode string `json:"mode"`
|
|
Invited int `json:"invited"`
|
|
To string `json:"to"`
|
|
RefersTo string `json:"refers_to"`
|
|
ApprovalPhrase string `json:"approval_phrase"`
|
|
}
|
|
if !decode(w, r, &body) {
|
|
return
|
|
}
|
|
|
|
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 {
|
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars))
|
|
return
|
|
}
|
|
// ask_owner is the owner's decision channel (#41). Only the owner and the
|
|
// owner-facing service roles may park one; an ordinary crow's header is
|
|
// dropped so the message tags as it otherwise would (harmless talk, not
|
|
// an error).
|
|
askOwner := body.Kind == kindAskOwner
|
|
if askOwner && body.Author != "owner" && !ownerFacingRoles[body.Author] {
|
|
body.Kind = ""
|
|
askOwner = false
|
|
}
|
|
|
|
if body.Author == "owner" {
|
|
// owner can always post
|
|
} else if !askOwner && !slices.Contains(nest.Crows, body.Author) {
|
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
|
|
return
|
|
}
|
|
if body.Author != "owner" {
|
|
if ok, msg, wait := s.p.Ledger.Admit(body.Author, time.Now(), s.p.G); !ok {
|
|
cooldown := int(wait.Seconds())
|
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{"message": msg, "cooldown_s": cooldown})
|
|
return
|
|
}
|
|
}
|
|
if body.Kind != "" && body.Author != "owner" && !askOwner && !crowPostable[body.Kind] {
|
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
|
|
return
|
|
}
|
|
// Watchdog enforcement (E3): a crow the Sentry flagged is silenced until
|
|
// the owner lifts the quarantine. The owner and the service roles never
|
|
// pass through this gate.
|
|
if body.Author != "owner" && !slices.Contains(serviceRoles, body.Author) && s.p.Ledger.IsQuarantined(body.Author) {
|
|
httpError(w, http.StatusForbidden, body.Author+" is quarantined pending the owner's decision.")
|
|
return
|
|
}
|
|
|
|
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
|
// A validated ask_owner header bypasses the classifier: Jev's kind
|
|
// question never proposes it, and a decision for the owner must not be
|
|
// re-tagged away.
|
|
var kind, taggedBy string
|
|
if askOwner {
|
|
kind, taggedBy = kindAskOwner, "header"
|
|
} else {
|
|
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
|
|
}
|
|
}
|
|
|
|
// Mode (Epic C, same rule shape as kind): Jev first, header escape, "" when unsure.
|
|
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
|
|
// conspiracy, which expects multiple claims. Directed assignment holds:
|
|
// while the asked crow has not answered, only it may claim.
|
|
if kind == "claim" && body.RefersTo != "" {
|
|
if origin, found := s.msgs.ByMsgID(body.RefersTo); found && origin.Mode != "conspiracy" {
|
|
if slices.Contains(nest.Crows, origin.To) && origin.To != body.Author && !s.msgs.Answered(origin.NestID, origin.MsgID(), origin.To) {
|
|
httpError(w, http.StatusConflict, origin.To+" was asked to take this; it answers or declines first.")
|
|
return
|
|
}
|
|
if owner, claimed := s.msgs.FirstClaim(origin.NestID, origin.MsgID()); claimed {
|
|
httpError(w, http.StatusConflict, origin.MsgID()+" is claimed by "+owner+"; review it instead of redoing it.")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// approval_phrase is honored only when the resulting kind is ask_owner.
|
|
phrase := strings.TrimSpace(body.ApprovalPhrase)
|
|
if kind != kindAskOwner {
|
|
phrase = ""
|
|
}
|
|
|
|
msg := Message{
|
|
NestID: nestID,
|
|
Kind: kind,
|
|
Author: body.Author,
|
|
Text: body.Text,
|
|
To: body.To,
|
|
RefersTo: body.RefersTo,
|
|
TaggedBy: taggedBy,
|
|
Mode: mode,
|
|
ModeTagged: modeBy,
|
|
Invited: invited,
|
|
ApprovalPhrase: phrase,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
record, err := s.msgs.Append(msg)
|
|
if err != nil {
|
|
httpError(w, http.StatusInternalServerError, "could not save the message: "+err.Error())
|
|
return
|
|
}
|
|
|
|
s.emitNestMessage(record)
|
|
s.triggerResponse(nest, record)
|
|
|
|
writeJSON(w, http.StatusCreated, s.nestRecord(record))
|
|
}
|
|
|
|
// triggerResponse is the event-driven response rule (no idle loops): a
|
|
// conspiracy invites several crows, each from its own lens; any other request
|
|
// gets exactly one responder through the matching path; a deliver gets one
|
|
// divergence check.
|
|
// It runs for owner-posted messages and for harness-posted ones alike, so a
|
|
// Quill relay answers the named crow exactly like a direct request (#40).
|
|
func (s *Server) triggerResponse(nest Nest, record Message) {
|
|
if record.Kind == "request" {
|
|
switch {
|
|
case record.Mode == "conspiracy" && record.Invited > 0:
|
|
go s.respondConspiracy(nest, record, record.Invited)
|
|
case record.To == "Elder":
|
|
go s.respondElder(nest, record)
|
|
case record.To == "Quill":
|
|
go s.respondQuill(nest, record)
|
|
case record.To == "Boss":
|
|
go s.respondBoss(nest, record)
|
|
case slices.Contains(nest.Crows, record.To):
|
|
go s.respondToRequest(nest, record)
|
|
default:
|
|
go s.respondOpenRequest(nest, record)
|
|
}
|
|
}
|
|
|
|
if record.Kind == "deliver" {
|
|
go s.checkDivergence(nest, record)
|
|
}
|
|
|
|
// Sentry watchdog (E3): watch every crow claim/deliver that answers a request.
|
|
if slices.Contains(nest.Crows, record.Author) && (record.Kind == "claim" || record.Kind == "deliver") {
|
|
go s.checkWatchdog(nest, record)
|
|
}
|
|
}
|
|
|
|
var modePostable = map[string]bool{"solo": true, "conspiracy": true, "split": true}
|
|
|
|
// tagMode classifies a request's mode (Jev first, header escape). Only requests
|
|
// carry a mode; other kinds get "".
|
|
func (s *Server) tagMode(kind, header, text string) (string, string) {
|
|
if kind != "request" {
|
|
return "", ""
|
|
}
|
|
if m, ok := s.p.Flock.ClassifyMode(s.ctx, text); ok {
|
|
if modePostable[header] && header != m {
|
|
return m, "disagreement"
|
|
}
|
|
return m, "jev"
|
|
}
|
|
if modePostable[header] {
|
|
return header, "header"
|
|
}
|
|
return "", "default"
|
|
}
|
|
|
|
// tagKind runs the A1 rule: Jev first; Jev unsure -> the poster's header;
|
|
// neither -> say. tagged_by records which source won, and a Jev override of a
|
|
// header logs as a disagreement (fuel for calibration).
|
|
func (s *Server) tagKind(header, text string) (string, string) {
|
|
kind, _, ok := s.p.Flock.ClassifyKind(s.ctx, text)
|
|
if ok {
|
|
if crowPostable[header] && header != kind {
|
|
return kind, "disagreement"
|
|
}
|
|
return kind, "jev"
|
|
}
|
|
if crowPostable[header] {
|
|
return header, "header"
|
|
}
|
|
return "say", "default"
|
|
}
|
|
|
|
func (s *Server) emitNestMessage(m Message) {
|
|
rec := s.nestRecord(m)
|
|
rec["nest_id"] = m.NestID
|
|
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
|
}
|
|
|
|
// inviteCount is how many crows a conspiracy asks to take the one task, capped
|
|
// by MurderSize, the flock's in-flight limit, and the nest's own size.
|
|
func (s *Server) inviteCount(nest Nest, requested int) int {
|
|
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 eligible crows; each answers from its own
|
|
// persona lens so distinct strengths produce distinct readings. Strangers are
|
|
// skipped: unprompted speech is earned (trust ladder, section 10).
|
|
func (s *Server) respondConspiracy(nest Nest, req Message, invited int) {
|
|
n := 0
|
|
for _, crow := range nest.Crows {
|
|
if n >= invited {
|
|
break
|
|
}
|
|
if !s.maySpeakUnprompted(crow) {
|
|
continue
|
|
}
|
|
n++
|
|
go s.respondOne(nest, req, crow, true)
|
|
}
|
|
}
|
|
|
|
// respondOpenRequest surfaces an unaddressed request to one regular-and-up
|
|
// crow as a claim opportunity (section 5: a regular may claim open requests).
|
|
func (s *Server) respondOpenRequest(nest Nest, req Message) {
|
|
for _, crow := range nest.Crows {
|
|
if s.maySpeakUnprompted(crow) {
|
|
go s.respondOne(nest, req, crow, false)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// maySpeakUnprompted is the trust gate for open (unaddressed) work. A crow the
|
|
// Sentry quarantined (E3) stays silent regardless of its level.
|
|
func (s *Server) maySpeakUnprompted(crow string) bool {
|
|
return s.p.Ledger.Level(crow, "other") > Stranger && !s.p.Ledger.IsQuarantined(crow)
|
|
}
|
|
|
|
// 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) {
|
|
if s.p.Ledger.IsQuarantined(req.To) {
|
|
return // a quarantined crow waits for the owner (E3)
|
|
}
|
|
s.respondOne(nest, req, req.To, false)
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
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: kind,
|
|
Author: crow,
|
|
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)
|
|
if record.Kind == "deliver" {
|
|
s.checkDivergence(nest, record)
|
|
}
|
|
}
|
|
}
|
|
|
|
// respondElder merges the reports on a thread: the Elder only speaks when a
|
|
// message asks it to (CHATROOM_SPEC section 6 — synthesis is requested, not a
|
|
// fixed step). It answers whoever opened the thread it merges.
|
|
func (s *Server) respondElder(nest Nest, req Message) {
|
|
if req.RefersTo == "" {
|
|
return
|
|
}
|
|
target, found := s.msgs.ByMsgID(req.RefersTo)
|
|
if !found {
|
|
return
|
|
}
|
|
var reports []string
|
|
for _, m := range s.msgs.ReportsTo(nest.ID, target.MsgID(), "deliver") {
|
|
if slices.Contains(nest.Crows, m.Author) {
|
|
reports = append(reports, "["+m.Author+"] "+m.Text)
|
|
}
|
|
}
|
|
if len(reports) == 0 {
|
|
return
|
|
}
|
|
budget := &Budget{name: "elder-merge", limit: s.p.G.TokenBudgetPerItem}
|
|
system := systemFor("Elder", elderSystem, true)
|
|
prompt := "REQUEST: " + target.Text + "\n--- reports to merge ---\n" + strings.Join(reports, "\n--- \n")
|
|
answer, err := s.p.Flock.ask(s.ctx, nil, "Elder", system, prompt, budget)
|
|
if err != nil {
|
|
return
|
|
}
|
|
reply := Message{
|
|
NestID: nest.ID,
|
|
Kind: "deliver",
|
|
Author: "Elder",
|
|
Text: strings.TrimSpace(answer),
|
|
To: target.Author,
|
|
RefersTo: target.MsgID(),
|
|
TaggedBy: "harness",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if record, err := s.msgs.Append(reply); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
}
|
|
|
|
// checkDivergence asks Jev whether this deliver restates earlier delivers that
|
|
// answer the same request. Above the band the crow gets a review nudge; the
|
|
// deliver itself stays in the log (nudges never silence).
|
|
func (s *Server) checkDivergence(nest Nest, m Message, budgets ...*Budget) {
|
|
if m.Kind != "deliver" {
|
|
return
|
|
}
|
|
earlier := s.msgs.EarlierDelivers(nest.ID, m.RefersTo, m.ID)
|
|
if len(earlier) == 0 {
|
|
return
|
|
}
|
|
if _, nudged := s.msgs.NudgeFor(nest.ID, m.MsgID()); nudged {
|
|
return // one nudge per deliver; no loop
|
|
}
|
|
prob, ok := s.p.Flock.ClassifyDivergence(s.ctx, map[string]any{"this": m.Text, "earlier": earlier}, budgets...)
|
|
if !ok || prob < s.p.Flock.jev.threshold {
|
|
return
|
|
}
|
|
nudge := Message{
|
|
NestID: nest.ID,
|
|
Kind: "nudge",
|
|
Author: "harness",
|
|
Text: "This reads like a point already made in the thread; review the earlier delivers and add your distinct angle or let it rest.",
|
|
To: m.Author,
|
|
RefersTo: m.MsgID(),
|
|
TaggedBy: "harness",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if record, err := s.msgs.Append(nudge); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
}
|
|
|
|
// checkWatchdog is the Sentry's off-task check (E3, JEV_STRATEGIES strategies
|
|
// 4 and 8): one Jev call per crow claim or deliver against the request it
|
|
// answers. A verdict at or above the band posts a flag and quarantines the
|
|
// crow. Owner messages are never inspected; at most one flag per crow per
|
|
// referent; quarantine sticks until the owner lifts it (ask_owner lands in
|
|
// #45).
|
|
func (s *Server) checkWatchdog(nest Nest, m Message) {
|
|
if m.Author == "owner" || m.RefersTo == "" {
|
|
return
|
|
}
|
|
target, found := s.msgs.ByMsgID(m.RefersTo)
|
|
if !found {
|
|
return
|
|
}
|
|
for _, x := range s.msgs.All(nest.ID) {
|
|
if x.Kind != "flag" {
|
|
continue
|
|
}
|
|
if flagged, ok := s.msgs.ByMsgID(x.RefersTo); ok && flagged.Author == m.Author && flagged.RefersTo == m.RefersTo {
|
|
return // this crow is already flagged on this expectation
|
|
}
|
|
}
|
|
var earlier []string
|
|
for _, d := range s.msgs.ReportsTo(nest.ID, m.RefersTo, "deliver") {
|
|
if d.ID != m.ID {
|
|
earlier = append(earlier, d.Text)
|
|
}
|
|
}
|
|
state := map[string]any{"request": target.Text, "crow": m.Author, "message": m.Text, "earlier": earlier}
|
|
budget := &Budget{name: "watchdog", limit: s.p.G.TokenBudgetPerItem}
|
|
scores, ok := s.p.Flock.ClassifyWatch(s.ctx, state, budget)
|
|
if !ok {
|
|
return
|
|
}
|
|
thr := s.p.Flock.jev.threshold
|
|
answers, hasAnswers := scores["answers_request"]
|
|
if scores["states_fact_without_source"] < thr && scores["contradicts_earlier"] < thr &&
|
|
(!hasAnswers || answers > 1-thr) {
|
|
return // below band on every question: say nothing, do nothing
|
|
}
|
|
var parts []string
|
|
for _, q := range []string{"answers_request", "states_fact_without_source", "contradicts_earlier"} {
|
|
if v, has := scores[q]; has {
|
|
parts = append(parts, fmt.Sprintf("%s %.2f", q, v))
|
|
}
|
|
}
|
|
flag := Message{
|
|
NestID: nest.ID,
|
|
Kind: "flag",
|
|
Author: "Sentry",
|
|
Text: fmt.Sprintf("%s looks off-task: %s (request: %.60s)", m.Author, strings.Join(parts, ", "), target.Text),
|
|
To: "owner",
|
|
RefersTo: m.MsgID(),
|
|
TaggedBy: "harness",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if record, err := s.msgs.Append(flag); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
s.p.Ledger.Quarantine(m.Author)
|
|
s.p.save()
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
if record, err := s.msgs.Append(flag); err == nil {
|
|
s.emitNestMessage(record)
|
|
}
|
|
}
|