diff --git a/quill.go b/quill.go index eb2c403..8292c9e 100644 --- a/quill.go +++ b/quill.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "time" ) @@ -8,13 +9,43 @@ import ( const quillSystem = `You are Quill crow, the owner's assistant and point of contact in a nest of AI crows. Answer the owner in one or two plain sentences. You are text-only: no web, no files, no tools.` +const quillHistoryCap = 20 + +// quillHistory builds a block of the most recent nest messages (excluding the +// requesting message), formatted as "[author|kind] text" with text truncated +// to 200 runes. Returns a single line when no earlier messages exist. +func quillHistory(all []Message, excludeID int) string { + var filtered []Message + for _, m := range all { + if m.ID != excludeID { + filtered = append(filtered, m) + } + } + if len(filtered) == 0 { + return "The nest has no earlier messages." + } + if len(filtered) > quillHistoryCap { + filtered = filtered[len(filtered)-quillHistoryCap:] + } + var sb strings.Builder + for _, m := range filtered { + text := []rune(m.Text) + if len(text) > 200 { + text = text[:200] + } + sb.WriteString(fmt.Sprintf("[%s|%s] %s\n", m.Author, m.Kind, string(text))) + } + return strings.TrimRight(sb.String(), "\n") +} + // respondQuill answers a message addressed to Quill, the assistant service -// role (CHATROOM_SPEC section 6). D1: one in-character reply through the -// flock. Reading the nest for status (D2) and relaying (D3) extend this file. +// role (CHATROOM_SPEC section 6). D2: reads the nest for status before +// composing its reply. func (s *Server) respondQuill(nest Nest, req Message) { budget := &Budget{name: "quill-reply", limit: s.p.G.TokenBudgetPerItem} system := systemFor("Quill", quillSystem, false) - prompt := "FROM: " + req.Author + "\nMESSAGE: " + req.Text + history := quillHistory(s.msgs.All(nest.ID), req.ID) + prompt := history + "\n---\nFROM: " + req.Author + "\nMESSAGE: " + req.Text answer, err := s.p.Flock.ask(s.ctx, nil, "Quill", system, prompt, budget) if err != nil { return diff --git a/quill_test.go b/quill_test.go index cbbb9a2..2032823 100644 --- a/quill_test.go +++ b/quill_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "net/http" "net/http/httptest" "strings" @@ -95,3 +96,119 @@ func TestQuillNotInNestCrows(t *testing.T) { t.Fatal("Quill must not need or earn a worker trust record") } } + +func TestQuillPromptContainsNestHistory(t *testing.T) { + ts, _, qb := newQuillServer(t, "Hugin finished the build already.") + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + + // 1. owner says something that sets up context + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Hugin","kind":"say","text":"build the widget factory"}`) + if code != http.StatusCreated { + t.Fatalf("post say: %d", code) + } + sayMsgID := out["msg_id"].(string) + + // 2. Hugin delivers an answer + code, _ = post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"Hugin","kind":"deliver","text":"the widget factory is live","refers_to":"`+sayMsgID+`"}`) + if code != http.StatusCreated { + t.Fatalf("post deliver: %d", code) + } + + // 3. owner asks Quill for status + code, _ = post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Quill","kind":"request","text":"where did that get to"}`) + if code != http.StatusCreated { + t.Fatalf("post request to Quill: %d", code) + } + + // Wait for Quill's reply (3 prior + 1 Quill reply = 4) + msgs := awaitMessages(t, ts, id, 4) + var quillReply map[string]any + for _, m := range msgs { + if m["author"] == "Quill" { + quillReply = m + } + } + if quillReply == nil { + t.Fatalf("Quill never answered: %v", msgs) + } + + qb.mu.Lock() + defer qb.mu.Unlock() + if len(qb.pairs) != 1 { + t.Fatalf("want exactly one model call, got %d", len(qb.pairs)) + } + prompt := qb.pairs[0][1] + if !strings.Contains(prompt, "build the widget factory") { + t.Fatalf("prompt must contain the owner's earlier message: %q", prompt) + } + if !strings.Contains(prompt, "the widget factory is live") { + t.Fatalf("prompt must contain Hugin's deliver: %q", prompt) + } +} + +func TestQuillEmptyNestSaysQuiet(t *testing.T) { + ts, _, qb := newQuillServer(t, "All quiet here.") + id := newNest(t, ts, "Quiet", `"Hugin"`) + + // Only message is the request to Quill — history excluding it is empty + code, _ := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Quill","kind":"request","text":"any updates?"}`) + if code != http.StatusCreated { + t.Fatalf("post: %d", code) + } + awaitMessages(t, ts, id, 2) + + qb.mu.Lock() + defer qb.mu.Unlock() + if len(qb.pairs) != 1 { + t.Fatalf("want exactly one model call, got %d", len(qb.pairs)) + } + prompt := qb.pairs[0][1] + if !strings.Contains(prompt, "no earlier messages") { + t.Fatalf("prompt must state the nest is empty: %q", prompt) + } +} + +func TestQuillHistoryCap(t *testing.T) { + ts, _, qb := newQuillServer(t, "got it") + id := newNest(t, ts, "Busy", `"Hugin"`) + + // Post 25 filler messages from owner + for i := 0; i < 25; i++ { + code, _ := post(t, ts.URL+"/api/nests/"+id+"/messages", + fmt.Sprintf(`{"author":"owner","kind":"say","text":"filler line %d"}`, i)) + if code != http.StatusCreated { + t.Fatalf("post filler %d: %d", i, code) + } + } + + // Ask Quill + code, _ := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Quill","kind":"request","text":"status"}`) + if code != http.StatusCreated { + t.Fatalf("post to Quill: %d", code) + } + awaitMessages(t, ts, id, 27) + + qb.mu.Lock() + defer qb.mu.Unlock() + if len(qb.pairs) != 1 { + t.Fatalf("want exactly one model call, got %d", len(qb.pairs)) + } + prompt := qb.pairs[0][1] + historyLines := 0 + for _, line := range strings.Split(prompt, "\n") { + if strings.HasPrefix(line, "[") { + historyLines++ + } + } + if historyLines > 20 { + t.Fatalf("history lines must be capped at 20, got %d\nprompt:\n%s", historyLines, prompt) + } + if historyLines == 0 { + t.Fatalf("expected some history lines, got none") + } +}