server: bot model layer — Claude-backed replies for the Go runtime (#309)
The conversational reply path of agent/runtime.mjs in Go, reached via the recipe 'model' action. internal/bot/anthropic.go is a minimal net/http Messages client (no new dependency); brain.go layers the system prompt (non-overridable safety preamble -> console persona -> fellow-helpers line -> docs), marks it cacheable, sends adaptive thinking + chip tools, self-heals the promised-button case with a forced-tool retry, and gates listen-mode 'name' behind the Haiku classifier — all as in the Node runtime. Set ANTHROPIC_API_KEY and the bot self-declares kind 'ai' and upgrades from the command floor to conversational replies; with no key the model rule falls through, so a recipe reads the same with or without a brain. The three shipped bots (Announcer, deterministic floor, conversational Eleven/Claude) now reach Node parity. The ThreadCtx handed to a Handler carries the thread's services (history, typing, ModelReply). Adds a Ready hook on Runtime/Engine (fired once the bot is live) so a supervisor — and the model e2e test — can know the connection is up before posting; this also removed a latent connect-window race in the tests.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+826
−88
bot_run_test.go+121 −26botcmd.go+4 −1docs/bot-recipes.md+14 −4docs/bots.md+17 −7internal/bot/anthropic.go+115 −0internal/bot/brain.go+341 −0internal/bot/brain_test.go+47 −0internal/bot/commands.go+2 −2internal/bot/commands_test.go+4 −4internal/bot/recipe.go+30 −12internal/bot/recipe_test.go+7 −7internal/bot/runtime.go+124 −25
diff --git a/bot_run_test.go b/bot_run_test.go| index 947ed0d..9c00d1e 100644 |
| --- a/bot_run_test.go |
| +++ b/bot_run_test.go |
| @@ -5,8 +5,10 @@ import ( |
| "context" |
| "encoding/json" |
| "net/http" |
| + "net/http/httptest" |
| "os" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -121,6 +123,18 @@ func waitFor(t *testing.T, what string, cond func() bool) { |
| t.Fatalf("timed out waiting for %s", what) |
| } |
| +// awaitReady blocks until the bot is live (reading frames). A message posted |
| +// before then is past the connect-time cursor and would only surface on a |
| +// reconnect — so tests that post-then-assert must wait for this first. |
| +func awaitReady(t *testing.T, ready <-chan struct{}) { |
| + t.Helper() |
| + select { |
| + case <-ready: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("bot never became ready") |
| + } |
| +} |
| + |
| // TestBotRunCommandFloor drives the live runtime end-to-end against the real |
| // router: a provisioned bot connects over WebSocket, and the deterministic |
| // command floor answers a group @mention and a DM — with the non-mention group |
| @@ -132,19 +146,13 @@ func TestBotRunCommandFloor(t *testing.T) { |
| eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven) |
| + ready := make(chan struct{}) |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
| - rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Logf: t.Logf} |
| + rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), |
| + Ready: func() { close(ready) }, Logf: t.Logf} |
| go rt.Run(ctx) |
| - |
| - // declareKind runs during connect setup, so the stored kind doubles as |
| - // "the bot is connected and listening". |
| - uid, _ := s.db.deviceTokenUser(eleven.Token) |
| - waitFor(t, "bot to connect", func() bool { |
| - var kind string |
| - s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind) |
| - return kind == "bot" |
| - }) |
| + awaitReady(t, ready) |
| // A plain group message must not trigger the bot; the @mention after it must. |
| postAs(t, ts.URL, owner, groupID, groupKey, "just chatting amongst ourselves") |
| @@ -192,17 +200,13 @@ func TestBotRunIgnoresOtherAgents(t *testing.T) { |
| // Announcer in Eleven's (connections-only) roster. |
| threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven, announcer) |
| + ready := make(chan struct{}) |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
| - rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Logf: t.Logf} |
| + rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), |
| + Ready: func() { close(ready) }, Logf: t.Logf} |
| go rt.Run(ctx) |
| - |
| - uid, _ := s.db.deviceTokenUser(eleven.Token) |
| - waitFor(t, "bot to connect", func() bool { |
| - var kind string |
| - s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind) |
| - return kind == "bot" |
| - }) |
| + awaitReady(t, ready) |
| // An agent-authored mention must be skipped; prove it with an ordinary |
| // mention after it that gets the (single) reply. |
| @@ -246,22 +250,18 @@ func TestBotEngineRecipe(t *testing.T) { |
| t.Fatal(err) |
| } |
| + ready := make(chan struct{}) |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
| - eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: recipe, Logf: t.Logf} |
| + eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: recipe, |
| + Ready: func() { close(ready) }, Logf: t.Logf} |
| go eng.Run(ctx) |
| + awaitReady(t, ready) |
| // The changelog poller baselines silently on its first tick. |
| statePath := bot.StatePathFor(sessionPath) |
| waitFor(t, "announce baseline", func() bool { _, err := os.Stat(statePath); return err == nil }) |
| - uid, _ := s.db.deviceTokenUser(eleven.Token) |
| - waitFor(t, "bot to connect", func() bool { |
| - var kind string |
| - s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind) |
| - return kind == "bot" |
| - }) |
| - |
| // Unaddressed keyword → the watcher's templated nudge. |
| postAs(t, ts.URL, owner, groupID, groupKey, "morning guys") |
| waitFor(t, "nudge", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 }) |
| @@ -303,3 +303,98 @@ func TestBotEngineRecipe(t *testing.T) { |
| return false |
| }) |
| } |
| + |
| +// fakeAnthropic stands in for the Messages API: it records the last request and |
| +// returns a scripted reply (optionally with a tool_use chip). Pointed at via |
| +// ANTHROPIC_BASE_URL, it lets the model path run end-to-end with no network. |
| +type fakeAnthropic struct { |
| + srv *httptest.Server |
| + mu sync.Mutex |
| + lastReq map[string]any |
| + reply string |
| + tool map[string]any // e.g. {"name":"set_theme","input":{"theme":"lagoon"}} |
| + calls int |
| +} |
| + |
| +func newFakeAnthropic(t *testing.T, reply string, tool map[string]any) *fakeAnthropic { |
| + f := &fakeAnthropic{reply: reply, tool: tool} |
| + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var req map[string]any |
| + json.NewDecoder(r.Body).Decode(&req) |
| + f.mu.Lock() |
| + f.lastReq = req |
| + f.calls++ |
| + f.mu.Unlock() |
| + content := []map[string]any{} |
| + if f.reply != "" { |
| + content = append(content, map[string]any{"type": "text", "text": f.reply}) |
| + } |
| + if f.tool != nil { |
| + content = append(content, map[string]any{ |
| + "type": "tool_use", "name": f.tool["name"], "input": f.tool["input"], |
| + }) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"content": content, "stop_reason": "end_turn"}) |
| + })) |
| + t.Cleanup(f.srv.Close) |
| + return f |
| +} |
| + |
| +// TestBotEngineModelReply drives the model action end-to-end: a mentioned |
| +// message with no matching command reaches the "model" rule, which asks the |
| +// (fake) Claude endpoint and posts its reply with the chip it proposed. |
| +func TestBotEngineModelReply(t *testing.T) { |
| + fake := newFakeAnthropic(t, "Sure — switching you to Lagoon.", |
| + map[string]any{"name": "set_theme", "input": map[string]any{"theme": "lagoon"}}) |
| + t.Setenv("ANTHROPIC_BASE_URL", fake.srv.URL) |
| + |
| + ts, s := newTestServer(t) |
| + owner := provisionNamed(t, ts.URL, "Owner", false) |
| + eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| + groupID, groupKey := createGroupWith(t, ts.URL, s, owner, eleven) |
| + |
| + dir := t.TempDir() |
| + sessionPath := dir + "/eleven.session.json" |
| + if err := eleven.Save(sessionPath); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Default recipe (commands then model) covers this — no recipe file needed. |
| + ready := make(chan struct{}) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, |
| + Recipe: bot.DefaultRecipe(), AIKey: "test-key", |
| + Ready: func() { close(ready) }, Logf: t.Logf} |
| + go eng.Run(ctx) |
| + |
| + awaitReady(t, ready) |
| + // A model brain badges "ai". |
| + uid, _ := s.db.deviceTokenUser(eleven.Token) |
| + waitFor(t, "bot badge ai", func() bool { |
| + var kind string |
| + s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind) |
| + return kind == "ai" |
| + }) |
| + |
| + postAs(t, ts.URL, owner, groupID, groupKey, "@Eleven make it lagoon please") |
| + waitFor(t, "model reply", func() bool { return len(botReplies(t, s, groupID, groupKey, "Eleven")) > 0 }) |
| + replies := botReplies(t, s, groupID, groupKey, "Eleven") |
| + if replies[0].Text != "Sure — switching you to Lagoon." { |
| + t.Fatalf("bad model reply text: %+v", replies[0]) |
| + } |
| + if len(replies[0].Actions) != 1 || replies[0].Actions[0]["theme"] != "lagoon" { |
| + t.Fatalf("model reply missing chip: %+v", replies[0]) |
| + } |
| + |
| + // The request carried the cacheable system prompt and the triggering line. |
| + fake.mu.Lock() |
| + defer fake.mu.Unlock() |
| + sys, _ := fake.lastReq["system"].([]any) |
| + if len(sys) == 0 { |
| + t.Fatal("no system blocks sent") |
| + } |
| + block := sys[0].(map[string]any) |
| + if _, ok := block["cache_control"]; !ok { |
| + t.Error("system block not marked cacheable") |
| + } |
| +} |
diff --git a/botcmd.go b/botcmd.go| index d26701a..a1bb380 100644 |
| --- a/botcmd.go |
| +++ b/botcmd.go |
| @@ -137,7 +137,10 @@ func runBotRun(args []string) { |
| Session: sess, |
| SessionPath: path, |
| Recipe: recipe, |
| - Logf: log.Printf, |
| + // With a key the bots upgrade to conversational AI (and badge AI); |
| + // without one they run the deterministic floor and badge BOT. |
| + AIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")), |
| + Logf: log.Printf, |
| } |
| go func() { |
| if err := eng.Run(context.Background()); err != nil { |
diff --git a/docs/bot-recipes.md b/docs/bot-recipes.md| index b74d98a..ca1b28a 100644 |
| --- a/docs/bot-recipes.md |
| +++ b/docs/bot-recipes.md |
| @@ -70,13 +70,23 @@ are skipped. |
| - **`reply`** — post `text` in the thread, with `{name}` replaced by the |
| sender's name. Deterministic, no model call — per `docs/bots.md`, a fixed |
| template is often the *best* form for a nudge. Message rules only. |
| +- **`model`** — phrase the reply with Claude (the runtime's model layer, |
| + `internal/bot/brain.go`): builds context from thread history, calls the |
| + Messages API with the layered system prompt + chip tools, posts the result. |
| + Needs `ANTHROPIC_API_KEY` (the bot then badges `ai`); with no key it falls |
| + through to later rules, so a recipe reads the same with or without a brain. |
| + This is the one action that sends text to a provider — hence its place in a |
| + watcher matters (below). Message rules only. |
| - **`announce`** — post fresh changelog entries to every thread the bot is in |
| (the full Announcer behaviour, including the silent first-run baseline). |
| Changelog rules only. |
| -Planned, not in v1: a `model` action (phrase the response with the LLM — the |
| -runtime's model layer), a `schedule` trigger, and a private-DM nudge variant. |
| -Each lands as a schema addition with its own review, per the boundary above. |
| +The **default recipe** (no file) is `commands` then `model`: answer a command |
| +when addressed, else let the brain reply — exactly the Node sidecar with and |
| +without a key. |
| + |
| +Planned, not in v1: a `schedule` trigger and a private-DM nudge variant. Each |
| +lands as a schema addition with its own review, per the boundary above. |
| ## Watchers are loud |
| @@ -87,7 +97,7 @@ message"* at startup), and the consent story rides on it: adding a watcher to |
| a group is the consent moment, and the admin console should show the flag |
| wherever the bot appears. Detection stays in the sidecar — with a |
| deterministic `reply` action, a watched message **never leaves the box at |
| -all**; when the `model` action exists, only *matches* ever reach the provider. |
| +all**; with a `model` action, only *matches* ever reach the provider. |
| ## Execution |
diff --git a/docs/bots.md b/docs/bots.md| index 8c96c3b..3d9451b 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -127,15 +127,25 @@ drift-pinned to `web/static/bot-commands.js` by a test), so a keyless Go bot |
| answers exactly like a keyless Node one. One process hosts many identities |
| (one session file per bot on the command line), and one deliberate divergence: |
| the Go runtime answers the *first* message of a newly-seen thread (Node |
| -baselines the cursor past it and stays silent). Remaining for full Node |
| -parity: the model layer (Claude API + Eleven's persona; welcome stays |
| -client-side, see `docs/conversation-scripts.md`). **And recipes**: the runtime |
| -is now recipe-shaped (`internal/bot/recipe.go`, schema + rationale in |
| +baselines the cursor past it and stays silent). **And recipes**: the runtime |
| +is recipe-shaped (`internal/bot/recipe.go`, schema + rationale in |
| `docs/bot-recipes.md`) — behaviour as an ordered trigger→action rule list from |
| -a bounded palette (message × trigger/any/keyword/regex → commands/reply; |
| +a bounded palette (message × trigger/any/keyword/regex → commands/reply/model; |
| changelog → announce), validated fail-closed, one `<session>.recipe.json` per |
| -identity, watcher rules loudly flagged. The Announcer is expressible as a |
| -recipe, and the planned `model` action is where the Claude layer lands. |
| +identity, watcher rules loudly flagged. **And the model layer**: `brain.go` is |
| +the Claude-backed reply path (`internal/bot/anthropic.go`, a minimal net/http |
| +Messages client — no new dependency) reached via the recipe `model` action. |
| +Set `ANTHROPIC_API_KEY` and the bot self-declares kind `ai` and upgrades from |
| +the command floor to conversational replies — same layered system prompt |
| +(non-overridable safety preamble → console persona → fellow-helpers line → |
| +docs), cache-marked system block, adaptive thinking, the chip tools |
| +(drift-pinned to `actions.mjs`), the promised-button forced-retry self-heal, |
| +and the listen-mode "name" classifier gate — all as in `runtime.mjs`. **The Go |
| +port now reaches Node parity for the three shipped bots** (Announcer, the |
| +deterministic floor, and conversational Eleven/Claude); what stays in Node is |
| +only the connected-services capability ops (`handleConnect`/`handleOAuthExchange`) |
| +and Eleven's onboarding welcomer, which is deliberately moving client-side as |
| +data (`docs/conversation-scripts.md`), not into the sidecar. |
| ## What exists today (`agent/`) |
diff --git a/internal/bot/anthropic.go b/internal/bot/anthropic.go| new file mode 100644 |
| index 0000000..22d129b |
| --- /dev/null |
| +++ b/internal/bot/anthropic.go |
| @@ -0,0 +1,115 @@ |
| +package bot |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "os" |
| + "time" |
| +) |
| + |
| +// A minimal Anthropic Messages API client — just the slice the bot brain |
| +// needs (system blocks with cache markers, adaptive thinking, tools), on |
| +// net/http so the static binary takes no new dependency. Base URL and key |
| +// come from the same env the SDKs read (ANTHROPIC_API_KEY, |
| +// ANTHROPIC_BASE_URL), so tests point it at an httptest server. |
| + |
| +const anthropicVersion = "2023-06-01" |
| + |
| +type anthropicClient struct { |
| + key string |
| + base string |
| + http *http.Client |
| +} |
| + |
| +func newAnthropicClient(key string) *anthropicClient { |
| + base := os.Getenv("ANTHROPIC_BASE_URL") |
| + if base == "" { |
| + base = "https://api.anthropic.com" |
| + } |
| + // Long timeout: a thinking run over a large cached system prompt can |
| + // legitimately take a couple of minutes. |
| + return &anthropicClient{key: key, base: base, http: &http.Client{Timeout: 5 * time.Minute}} |
| +} |
| + |
| +type anthBlock struct { |
| + Type string `json:"type"` |
| + Text string `json:"text,omitempty"` |
| + // CacheControl marks the prefix up to this block cacheable (the big system |
| + // prompt rides every call — see systemBlocks in brain.go). |
| + CacheControl *anthCache `json:"cache_control,omitempty"` |
| + // tool_use response fields |
| + Name string `json:"name,omitempty"` |
| + Input map[string]any `json:"input,omitempty"` |
| +} |
| + |
| +type anthCache struct { |
| + Type string `json:"type"` // "ephemeral" |
| +} |
| + |
| +type anthMessage struct { |
| + Role string `json:"role"` |
| + Content string `json:"content"` |
| +} |
| + |
| +type anthTool struct { |
| + Name string `json:"name"` |
| + Description string `json:"description"` |
| + InputSchema map[string]any `json:"input_schema"` |
| +} |
| + |
| +type anthRequest struct { |
| + Model string `json:"model"` |
| + MaxTokens int `json:"max_tokens"` |
| + System []anthBlock `json:"system,omitempty"` |
| + Thinking map[string]any `json:"thinking,omitempty"` |
| + Tools []anthTool `json:"tools,omitempty"` |
| + ToolChoice map[string]any `json:"tool_choice,omitempty"` |
| + Messages []anthMessage `json:"messages"` |
| +} |
| + |
| +type anthResponse struct { |
| + Content []anthBlock `json:"content"` |
| + StopReason string `json:"stop_reason"` |
| +} |
| + |
| +func (c *anthropicClient) create(req anthRequest) (*anthResponse, error) { |
| + body, err := json.Marshal(req) |
| + if err != nil { |
| + return nil, err |
| + } |
| + hr, err := http.NewRequest("POST", c.base+"/v1/messages", bytes.NewReader(body)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + hr.Header.Set("Content-Type", "application/json") |
| + hr.Header.Set("x-api-key", c.key) |
| + hr.Header.Set("anthropic-version", anthropicVersion) |
| + resp, err := c.http.Do(hr) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) |
| + return nil, fmt.Errorf("anthropic: %d: %s", resp.StatusCode, msg) |
| + } |
| + var out anthResponse |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + return nil, err |
| + } |
| + return &out, nil |
| +} |
| + |
| +// text joins the response's text blocks. |
| +func (r *anthResponse) text() string { |
| + var b bytes.Buffer |
| + for _, blk := range r.Content { |
| + if blk.Type == "text" { |
| + b.WriteString(blk.Text) |
| + } |
| + } |
| + return b.String() |
| +} |
diff --git a/internal/bot/brain.go b/internal/bot/brain.go| new file mode 100644 |
| index 0000000..cc93868 |
| --- /dev/null |
| +++ b/internal/bot/brain.go |
| @@ -0,0 +1,341 @@ |
| +package bot |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "regexp" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// The model brain: the Claude-backed reply path of agent/runtime.mjs in Go. |
| +// It rides the same Runtime as the deterministic floor — recipes reach it via |
| +// the "model" action — and holds the line the trust model draws: the |
| +// ANTHROPIC_API_KEY and decrypted text meet only here, in the sidecar process, |
| +// never in the server (CLAUDE.md, the one rule; docs/bots.md). |
| + |
| +const defaultModel = "claude-sonnet-5" |
| + |
| +// classifierModel runs the bare-name "am I being addressed?" gate — a tiny |
| +// yes/no call, so always the cheapest model regardless of the bot's own. |
| +const classifierModel = "claude-haiku-4-5" |
| + |
| +const defaultPersona = "Keep replies short, warm, and easy to read." |
| + |
| +// safety is the non-overridable preamble; the console-set persona and docs |
| +// layer under it (same text as the Node runtime's SAFETY). |
| +func safety(name string) string { |
| + app := os.Getenv("LCHAT_APP_NAME") |
| + if app == "" { |
| + app = "Eleven" |
| + } |
| + return fmt.Sprintf("You are %s, an AI assistant and a member of %s — a small, private, "+ |
| + "end-to-end encrypted group chat app; the server and its operator can't read messages, "+ |
| + "and that privacy is the whole point of the app. You are one participant among the people here. "+ |
| + "You may be in a one-to-one DM or in a group; in a group you're brought in only when someone addresses you directly (usually an @mention). "+ |
| + "You can only take part in the conversation — no reminders, notifications, files, or web access — "+ |
| + "so never promise to do those things; if asked, say you can't yet. "+ |
| + "This chat may include children: always be kind and age-appropriate, and never say anything scary, adult, or unsafe. "+ |
| + "Each message is prefixed with the sender's name — reply naturally and don't prefix your own name.", name, app) |
| +} |
| + |
| +// botConfig is the console-managed config (GET /api/bot-config) applied over |
| +// env fallbacks. Re-fetched on every (re)connect so admin edits take effect. |
| +type botConfig struct { |
| + SystemPrompt string `json:"system_prompt"` |
| + Model string `json:"model"` |
| + Docs string `json:"docs"` |
| + Listen string `json:"listen"` |
| +} |
| + |
| +func (r *Runtime) loadConfig() { |
| + var c botConfig |
| + if err := r.get("/api/bot-config", &c); err != nil { |
| + r.logf("loadConfig: %v", err) |
| + } |
| + r.persona = firstOf(c.SystemPrompt, os.Getenv("LCHAT_SYSTEM"), defaultPersona) |
| + r.model = firstOf(c.Model, os.Getenv("LCHAT_MODEL"), defaultModel) |
| + r.docs = c.Docs |
| + r.listen = firstOf(c.Listen, os.Getenv("LCHAT_LISTEN"), "mention") |
| +} |
| + |
| +func firstOf(vals ...string) string { |
| + for _, v := range vals { |
| + if v != "" { |
| + return v |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// systemPrompt layers: safety preamble, persona, fellow-helpers line (so one |
| +// bot seeing another's message doesn't treat it as an impostor), the |
| +// buttons-need-tool-calls rule, then the reference docs. |
| +func (r *Runtime) systemPrompt() string { |
| + s := safety(r.Session.Name) + "\n\n" + r.persona |
| + var others []string |
| + for n := range r.agents { |
| + if n != r.Session.Name { |
| + others = append(others, n) |
| + } |
| + } |
| + if len(others) > 0 { |
| + s += "\n\nOther helper bots are members of this server alongside you: " + strings.Join(others, ", ") + ". " + |
| + "They are legitimate, separately-run assistants (Eleven guides people around the app; " + |
| + "Announcer posts release notes; Claude is a general companion). You may see their messages " + |
| + "in shared groups; you never reply to them, and there's no need to question their presence." |
| + } |
| + // Context is rebuilt from message TEXT only, so the model sees its own past |
| + // chip replies as bare texts and starts imitating them without the tool |
| + // call — make the rule unconditional (see the forced retry in modelReply). |
| + s += "\n\nYour buttons only exist when you call the matching tool: every reply that offers " + |
| + "a one-tap action (\"tap to switch\", \"here you go\") MUST include that tool call — text alone " + |
| + "attaches nothing, even if earlier replies in the conversation look like plain text. " + |
| + "Never tell someone a button is attached without calling the tool in that same reply. " + |
| + "\"[note to self: …]\" lines in the history are added automatically where you called tools " + |
| + "before — never write them yourself." |
| + if r.docs != "" { |
| + s += "\n\nAlways keep this reference in mind:\n" + r.docs |
| + } |
| + return s |
| +} |
| + |
| +// systemBlocks marks the (stable, potentially large) system prompt cacheable — |
| +// one trailing breakpoint caches everything; small prompts fall below the |
| +// cache floor and are harmlessly uncached. |
| +func (r *Runtime) systemBlocks() []anthBlock { |
| + return []anthBlock{{Type: "text", Text: r.systemPrompt(), CacheControl: &anthCache{Type: "ephemeral"}}} |
| +} |
| + |
| +// buildContext folds recent thread history into the Claude message list. |
| +// Self-identity is the session name, and history stays truthful about past |
| +// tool calls via the note-to-self line. Assistant turns are trimmed from both |
| +// ends (the API needs a leading user turn; a trailing assistant turn is a |
| +// hard 400 under thinking). |
| +func (r *Runtime) buildContext(tc *ThreadCtx) ([]anthMessage, error) { |
| + hist, err := tc.History(30) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []anthMessage |
| + for _, m := range hist { |
| + if m.Text == "" { |
| + continue |
| + } |
| + if m.Mine { |
| + content := m.Text |
| + if m.ActionCount > 0 { |
| + content += fmt.Sprintf("\n[note to self: this reply attached %d button(s) via tool calls]", m.ActionCount) |
| + } |
| + out = append(out, anthMessage{Role: "assistant", Content: content}) |
| + } else { |
| + out = append(out, anthMessage{Role: "user", Content: m.Name + ": " + m.Text}) |
| + } |
| + } |
| + for len(out) > 0 && out[0].Role == "assistant" { |
| + out = out[1:] |
| + } |
| + for len(out) > 0 && out[len(out)-1].Role == "assistant" { |
| + out = out[:len(out)-1] |
| + } |
| + return out, nil |
| +} |
| + |
| +var promisedButton = regexp.MustCompile(`(?i)\b(tap|button|chip)\b`) |
| + |
| +// modelReply is the conversational path: build context, ask Claude (adaptive |
| +// thinking, chip tools), self-heal the promised-button case, return the reply. |
| +// ok=false (no key, API error, empty reply) means "stay silent" — the recipe |
| +// falls through to later rules or the degrade hint. |
| +func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| + if r.ai == nil { |
| + return Reply{}, false |
| + } |
| + // Best-effort "…is typing", re-beaten for the whole generation: a single |
| + // beat's 15s expiry lapses under a long thinking run. |
| + stop := make(chan struct{}) |
| + go func() { |
| + tc.Typing(15) |
| + t := time.NewTicker(10 * time.Second) |
| + defer t.Stop() |
| + for { |
| + select { |
| + case <-t.C: |
| + tc.Typing(15) |
| + case <-stop: |
| + // An errored/empty run sends no message (the guaranteed clear |
| + // signal) — stop the indicator explicitly. |
| + tc.Typing(0) |
| + return |
| + } |
| + } |
| + }() |
| + defer close(stop) |
| + |
| + context, err := r.buildContext(tc) |
| + if err != nil { |
| + r.logf("buildContext: %v", err) |
| + return Reply{}, false |
| + } |
| + if len(context) == 0 { |
| + context = []anthMessage{{Role: "user", Content: "Say hello."}} |
| + } |
| + resp, err := r.ai.create(anthRequest{ |
| + Model: r.model, |
| + // Thinking, text and any tool call share this budget — a tight cap |
| + // truncates the tool_use off the end and the chip silently vanishes. |
| + MaxTokens: 4096, |
| + System: r.systemBlocks(), |
| + Thinking: map[string]any{"type": "adaptive"}, |
| + Tools: chipTools(), |
| + Messages: context, |
| + }) |
| + if err != nil { |
| + r.logf("claude: %v", err) |
| + return Reply{}, false |
| + } |
| + text := strings.TrimSpace(resp.text()) |
| + actions := actionsFromToolUse(resp.Content) |
| + // Self-heal the promised-button case: threads full of pre-annotation chip |
| + // texts bait the model into promising a button WITHOUT the tool call. The |
| + // promise is detectable, so make it deterministic: ask once more with a |
| + // forced tool call (forced tool_choice requires thinking off). |
| + if len(actions) == 0 && promisedButton.MatchString(text) { |
| + r.logf("reply promised a button without a tool call — retrying forced") |
| + resp2, err := r.ai.create(anthRequest{ |
| + Model: r.model, |
| + MaxTokens: 4096, |
| + System: r.systemBlocks(), |
| + Thinking: map[string]any{"type": "disabled"}, |
| + Tools: chipTools(), |
| + ToolChoice: map[string]any{"type": "any"}, |
| + Messages: context, |
| + }) |
| + if err == nil { |
| + actions = actionsFromToolUse(resp2.Content) |
| + if t2 := strings.TrimSpace(resp2.text()); len(actions) > 0 && t2 != "" { |
| + text = t2 // keep the original text if the retry was tool-only |
| + } |
| + } |
| + } |
| + if text == "" && len(actions) == 0 { |
| + r.logf("empty reply dropped (stop_reason=%s)", resp.StopReason) |
| + return Reply{}, false |
| + } |
| + return Reply{Text: text, Actions: actions}, true |
| +} |
| + |
| +// isAddressed is the bare-name gate for listen mode "name": one tiny yes/no |
| +// call over the last few messages. Errs toward silence — @Name is the |
| +// guaranteed path, so a false negative is always recoverable by the sender. |
| +func (r *Runtime) isAddressed(tc *ThreadCtx) bool { |
| + hist, err := tc.History(6) |
| + if err != nil { |
| + return false |
| + } |
| + var lines []string |
| + for _, m := range hist { |
| + if m.Text != "" { |
| + lines = append(lines, m.Name+": "+m.Text) |
| + } |
| + } |
| + if len(lines) == 0 { |
| + return false |
| + } |
| + resp, err := r.ai.create(anthRequest{ |
| + Model: classifierModel, |
| + MaxTokens: 5, |
| + System: []anthBlock{{Type: "text", Text: fmt.Sprintf( |
| + `"%s" is a chat bot in a group conversation. Given the recent messages, decide whether `+ |
| + `the LAST message is addressed to %s and expects %s to reply — as opposed to merely `+ |
| + `mentioning %s or talking about %s. Answer with exactly "yes" or "no".`, |
| + r.Session.Name, r.Session.Name, r.Session.Name, r.Session.Name, r.Session.Name)}}, |
| + Messages: []anthMessage{{Role: "user", Content: strings.Join(lines, "\n")}}, |
| + }) |
| + if err != nil { |
| + r.logf("addressed-classifier: %v", err) |
| + return false |
| + } |
| + return strings.Contains(strings.ToLower(resp.text()), "yes") |
| +} |
| + |
| +// chipTools are the one-tap action chips the model may propose — the Go twin |
| +// of agent/actions.mjs CHIP_TOOLS (names drift-checked by test). The CLIENT is |
| +// the whitelist that matters; this only decides what the model can propose, |
| +// and chips never auto-run. |
| +func chipTools() []anthTool { |
| + themeList := make([]any, len(themes)) |
| + for i, t := range themes { |
| + themeList[i] = t |
| + } |
| + return []anthTool{ |
| + { |
| + Name: "set_theme", |
| + Description: "Attach a one-tap button that switches the member's colour theme when THEY tap it. " + |
| + "Themes: litroom (warm amber 'Lit Room'), lagoon (teal), sunny (yellow), plum (purple), " + |
| + "linen (soft neutral), classic (blue). Use when someone asks to change the look/colours.", |
| + InputSchema: map[string]any{ |
| + "type": "object", |
| + "properties": map[string]any{"theme": map[string]any{"type": "string", "enum": themeList}}, |
| + "required": []any{"theme"}, |
| + }, |
| + }, |
| + { |
| + Name: "set_appearance", |
| + Description: "Attach a one-tap button that switches light/dark mode when the member taps it. " + |
| + "'system' follows their device. Use when someone asks for light mode, dark mode, etc.", |
| + InputSchema: map[string]any{ |
| + "type": "object", |
| + "properties": map[string]any{"appearance": map[string]any{"type": "string", "enum": []any{"light", "dark", "system"}}}, |
| + "required": []any{"appearance"}, |
| + }, |
| + }, |
| + { |
| + Name: "open_theme_picker", |
| + Description: "Attach a one-tap button that opens the theme picker, for when they want to browse the options.", |
| + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, |
| + }, |
| + { |
| + Name: "open_profile", |
| + Description: "Attach a one-tap button that opens the member's own profile editor (screen name + photo).", |
| + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, |
| + }, |
| + } |
| +} |
| + |
| +var appearances = []string{"light", "dark", "system"} |
| + |
| +// actionsFromToolUse maps tool_use blocks to payload actions. Unknown tools |
| +// and malformed inputs are dropped (the client re-validates too). |
| +func actionsFromToolUse(content []anthBlock) []map[string]any { |
| + var out []map[string]any |
| + for _, b := range content { |
| + if b.Type != "tool_use" { |
| + continue |
| + } |
| + switch b.Name { |
| + case "set_theme": |
| + if th, _ := b.Input["theme"].(string); themeLabels[th] != "" { |
| + out = append(out, map[string]any{"type": "set_theme", "theme": th}) |
| + } |
| + case "set_appearance": |
| + if ap, _ := b.Input["appearance"].(string); contains(appearances, ap) { |
| + out = append(out, map[string]any{"type": "set_appearance", "appearance": ap}) |
| + } |
| + case "open_theme_picker": |
| + out = append(out, map[string]any{"type": "open_theme_picker"}) |
| + case "open_profile": |
| + out = append(out, map[string]any{"type": "open_profile"}) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func contains(list []string, v string) bool { |
| + for _, x := range list { |
| + if x == v { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/bot/brain_test.go b/internal/bot/brain_test.go| new file mode 100644 |
| index 0000000..b472517 |
| --- /dev/null |
| +++ b/internal/bot/brain_test.go |
| @@ -0,0 +1,47 @@ |
| +package bot |
| + |
| +import ( |
| + "os" |
| + "regexp" |
| + "testing" |
| +) |
| + |
| +// TestChipToolsMatchJS pins the Go chip-tool names to agent/actions.mjs |
| +// CHIP_TOOLS — same drift-proofing as the command floor. |
| +func TestChipToolsMatchJS(t *testing.T) { |
| + raw, err := os.ReadFile("../../agent/actions.mjs") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var js []string |
| + for _, m := range regexp.MustCompile(`name: "([a-z_]+)"`).FindAllStringSubmatch(string(raw), -1) { |
| + js = append(js, m[1]) |
| + } |
| + tools := chipTools() |
| + if len(js) != len(tools) { |
| + t.Fatalf("actions.mjs has %d tools, Go has %d", len(js), len(tools)) |
| + } |
| + for i, tool := range tools { |
| + if js[i] != tool.Name { |
| + t.Errorf("tool %d: js %q, go %q", i, js[i], tool.Name) |
| + } |
| + } |
| +} |
| + |
| +func TestActionsFromToolUse(t *testing.T) { |
| + content := []anthBlock{ |
| + {Type: "text", Text: "Here you go."}, |
| + {Type: "tool_use", Name: "set_theme", Input: map[string]any{"theme": "lagoon"}}, |
| + {Type: "tool_use", Name: "set_theme", Input: map[string]any{"theme": "neon"}}, // unknown theme → dropped |
| + {Type: "tool_use", Name: "set_appearance", Input: map[string]any{"appearance": "dark"}}, |
| + {Type: "tool_use", Name: "delete_everything", Input: map[string]any{}}, // unknown tool → dropped |
| + {Type: "tool_use", Name: "open_profile"}, |
| + } |
| + got := actionsFromToolUse(content) |
| + if len(got) != 3 { |
| + t.Fatalf("got %d actions: %+v", len(got), got) |
| + } |
| + if got[0]["theme"] != "lagoon" || got[1]["appearance"] != "dark" || got[2]["type"] != "open_profile" { |
| + t.Fatalf("bad actions: %+v", got) |
| + } |
| +} |
diff --git a/internal/bot/commands.go b/internal/bot/commands.go| index c087cf8..85f3fbf 100644 |
| --- a/internal/bot/commands.go |
| +++ b/internal/bot/commands.go |
| @@ -113,9 +113,9 @@ func mentionStripper(name string) func(string) string { |
| // answered in place and anything else gets the no-AI hint — the exact |
| // behaviour of the Node sidecar running without an ANTHROPIC_API_KEY. |
| // (Recipes fold the same floor in via the "commands" action.) |
| -func CommandFloor(name string, aiEnabled bool) func(Incoming) (Reply, bool) { |
| +func CommandFloor(name string, aiEnabled bool) func(*ThreadCtx, Incoming) (Reply, bool) { |
| strip := mentionStripper(name) |
| - return func(in Incoming) (Reply, bool) { |
| + return func(_ *ThreadCtx, in Incoming) (Reply, bool) { |
| if !in.Triggered { |
| return Reply{}, false |
| } |
diff --git a/internal/bot/commands_test.go b/internal/bot/commands_test.go| index 540e9fd..a72bddc 100644 |
| --- a/internal/bot/commands_test.go |
| +++ b/internal/bot/commands_test.go |
| @@ -83,20 +83,20 @@ func TestRunCommand(t *testing.T) { |
| func TestCommandFloor(t *testing.T) { |
| h := CommandFloor("Eleven", false) |
| // Group command with the mention prefix. |
| - rep, ok := h(Incoming{Kind: "group", Text: "@Eleven /dark", Triggered: true}) |
| + rep, ok := h(nil, Incoming{Kind: "group", Text: "@Eleven /dark", Triggered: true}) |
| if !ok || rep.Actions[0]["appearance"] != "dark" { |
| t.Fatalf("mention + command: ok=%v rep=%+v", ok, rep) |
| } |
| // Bare command in a DM. |
| - if rep, _ := h(Incoming{Kind: "dm", Text: "/light", Triggered: true}); rep.Actions[0]["appearance"] != "light" { |
| + if rep, _ := h(nil, Incoming{Kind: "dm", Text: "/light", Triggered: true}); rep.Actions[0]["appearance"] != "light" { |
| t.Fatalf("dm command: %+v", rep) |
| } |
| // Not a command → the deterministic hint. |
| - if rep, _ := h(Incoming{Kind: "dm", Text: "hi there", Triggered: true}); rep.Text != degradeText { |
| + if rep, _ := h(nil, Incoming{Kind: "dm", Text: "hi there", Triggered: true}); rep.Text != degradeText { |
| t.Fatalf("degrade: %+v", rep) |
| } |
| // Unaddressed messages never reach the floor's mouth. |
| - if _, ok := h(Incoming{Kind: "group", Text: "/help"}); ok { |
| + if _, ok := h(nil, Incoming{Kind: "group", Text: "/help"}); ok { |
| t.Fatal("unaddressed message must stay silent") |
| } |
| } |
diff --git a/internal/bot/recipe.go b/internal/bot/recipe.go| index c607b39..84df5e5 100644 |
| --- a/internal/bot/recipe.go |
| +++ b/internal/bot/recipe.go |
| @@ -58,10 +58,15 @@ type Rule struct { |
| // the session file it configures. |
| func RecipePathFor(sessionPath string) string { return sessionPath + ".recipe.json" } |
| -// DefaultRecipe is a bot with no recipe file: answer the command floor when |
| -// addressed — exactly the keyless Node sidecar's behaviour. |
| +// DefaultRecipe is a bot with no recipe file: when addressed, answer the |
| +// command floor, and let the model take anything that isn't a command — |
| +// exactly the Node sidecar's behaviour with and without an ANTHROPIC_API_KEY |
| +// (the model rule falls through harmlessly when there's no key). |
| func DefaultRecipe() *Recipe { |
| - return &Recipe{Rules: []Rule{{On: "message", Action: "commands"}}} |
| + return &Recipe{Rules: []Rule{ |
| + {On: "message", Action: "commands"}, |
| + {On: "message", Action: "model"}, |
| + }} |
| } |
| // LoadRecipe reads and validates a recipe file. A missing file returns |
| @@ -104,9 +109,9 @@ func (r *Recipe) validate() error { |
| return fmt.Errorf("rule %d: unknown match %q", i, rule.Match) |
| } |
| switch rule.Action { |
| - case "commands": |
| + case "commands", "model": |
| if rule.Text != "" { |
| - return fmt.Errorf("rule %d: commands takes no text", i) |
| + return fmt.Errorf("rule %d: %s takes no text", i, rule.Action) |
| } |
| case "reply": |
| if rule.Text == "" { |
| @@ -164,11 +169,12 @@ func (r *Recipe) Watches() bool { |
| // handler folds the message rules into the Runtime's Handler seam. First |
| // matching rule acts. A "commands" rule that was addressed but got a |
| -// non-command lets later rules try, then falls back to the no-AI hint — the |
| -// same UX as the plain command floor. |
| -func (r *Recipe) handler(botName string, aiEnabled bool) func(Incoming) (Reply, bool) { |
| +// non-command lets later rules try; if nothing else acts, an addressed |
| +// message falls back to the no-AI hint (only without a model — with one, a |
| +// failed model call stays silent, like the Node runtime). |
| +func (r *Recipe) handler(botName string, aiEnabled bool) func(*ThreadCtx, Incoming) (Reply, bool) { |
| strip := mentionStripper(botName) |
| - return func(in Incoming) (Reply, bool) { |
| + return func(tc *ThreadCtx, in Incoming) (Reply, bool) { |
| sawCommands := false |
| for _, rule := range r.Rules { |
| if rule.On != "message" { |
| @@ -192,9 +198,14 @@ func (r *Recipe) handler(botName string, aiEnabled bool) func(Incoming) (Reply, |
| sawCommands = true // not a command — later rules may still act |
| case "reply": |
| return Reply{Text: strings.ReplaceAll(rule.Text, "{name}", in.Sender)}, true |
| + case "model": |
| + if rep, ok := tc.ModelReply(); ok { |
| + return rep, true |
| + } |
| + // No brain, or the call failed — later rules may still act. |
| } |
| } |
| - if sawCommands && in.Triggered { |
| + if sawCommands && in.Triggered && !aiEnabled { |
| return Reply{Text: degradeText}, true |
| } |
| return Reply{}, false |
| @@ -209,7 +220,12 @@ type Engine struct { |
| Session *Session |
| SessionPath string // for the announce state file (StatePathFor) |
| Recipe *Recipe |
| - Logf func(format string, args ...any) |
| + // AIKey is the ANTHROPIC_API_KEY (empty = deterministic-only: model rules |
| + // fall through, the bot badges BOT, and nothing ever leaves the box). |
| + AIKey string |
| + // Ready, if set, is forwarded to the Runtime (called once the bot is live). |
| + Ready func() |
| + Logf func(format string, args ...any) |
| } |
| func (e *Engine) Run(ctx context.Context) error { |
| @@ -223,7 +239,9 @@ func (e *Engine) Run(ctx context.Context) error { |
| rt := &Runtime{ |
| Base: e.Base, |
| Session: e.Session, |
| - Handler: e.Recipe.handler(e.Session.Name, false), |
| + Handler: e.Recipe.handler(e.Session.Name, e.AIKey != ""), |
| + AIKey: e.AIKey, |
| + Ready: e.Ready, |
| Logf: e.Logf, |
| } |
| return rt.Run(ctx) |
diff --git a/internal/bot/recipe_test.go b/internal/bot/recipe_test.go| index abb4adc..e424ac2 100644 |
| --- a/internal/bot/recipe_test.go |
| +++ b/internal/bot/recipe_test.go |
| @@ -72,34 +72,34 @@ func TestRecipeHandler(t *testing.T) { |
| h := r.handler("Eleven", false) |
| // Watcher fires on an unaddressed group message, template fills the sender. |
| - rep, ok := h(Incoming{Kind: "group", Sender: "Paul", Text: "morning guys", Triggered: false}) |
| + rep, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning guys", Triggered: false}) |
| if !ok || rep.Text != `Hey Paul — how about "folks"?` { |
| t.Fatalf("nudge: ok=%v %+v", ok, rep) |
| } |
| // Unaddressed, no keyword → silent (commands rule is mention-gated). |
| - if _, ok := h(Incoming{Kind: "group", Sender: "Paul", Text: "morning all", Triggered: false}); ok { |
| + if _, ok := h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "morning all", Triggered: false}); ok { |
| t.Fatal("unaddressed non-match must stay silent") |
| } |
| // Addressed command answers via the floor (mention stripped first). |
| - rep, ok = h(Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven /dark", Triggered: true}) |
| + rep, ok = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven /dark", Triggered: true}) |
| if !ok || rep.Actions[0]["appearance"] != "dark" { |
| t.Fatalf("command via recipe: ok=%v %+v", ok, rep) |
| } |
| // Addressed non-command falls through the commands rule to the hint. |
| - rep, ok = h(Incoming{Kind: "dm", Sender: "Paul", Text: "hello", Triggered: true}) |
| + rep, ok = h(nil, Incoming{Kind: "dm", Sender: "Paul", Text: "hello", Triggered: true}) |
| if !ok || rep.Text != degradeText { |
| t.Fatalf("degrade via recipe: ok=%v %+v", ok, rep) |
| } |
| // Rule order: an addressed message that ALSO matches the watcher gets the |
| // first rule's action, not the floor. |
| - rep, _ = h(Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven you guys ok?", Triggered: true}) |
| + rep, _ = h(nil, Incoming{Kind: "group", Sender: "Paul", Text: "@Eleven you guys ok?", Triggered: true}) |
| if !strings.Contains(rep.Text, "folks") { |
| t.Fatalf("first matching rule must win: %+v", rep) |
| } |
| // Keyword gate is case-insensitive substring. |
| r2 := mustRecipe(t, `{"rules":[{"on":"message","match":"any","keyword":"standup","action":"reply","text":"Logged."}]}`) |
| - if _, ok := r2.handler("Bot", false)(Incoming{Text: "STANDUP done", Sender: "P"}); !ok { |
| + if _, ok := r2.handler("Bot", false)(nil, Incoming{Text: "STANDUP done", Sender: "P"}); !ok { |
| t.Fatal("keyword must match case-insensitively") |
| } |
| if r2.Watches() != true { |
| @@ -114,7 +114,7 @@ func TestRecipeHandler(t *testing.T) { |
| if d.Watches() { |
| t.Fatal("default recipe must not watch") |
| } |
| - if _, ok := d.handler("Bot", false)(Incoming{Kind: "group", Text: "/help", Triggered: false}); ok { |
| + if _, ok := d.handler("Bot", false)(nil, Incoming{Kind: "group", Text: "/help", Triggered: false}); ok { |
| t.Fatal("default recipe must ignore unaddressed messages") |
| } |
| } |
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go| index ed9c2a1..8691f0e 100644 |
| --- a/internal/bot/runtime.go |
| +++ b/internal/bot/runtime.go |
| @@ -30,24 +30,104 @@ import ( |
| type Runtime struct { |
| Base string |
| Session *Session |
| - // Handler decides the response to a message that triggered the bot. |
| - // Return ok=false to stay silent. |
| - Handler func(Incoming) (Reply, bool) |
| - // Kind is what we self-declare to the server so the UI badges us honestly: |
| - // "bot" (deterministic, the default) or "ai" (model brain present). |
| - Kind string |
| - Logf func(format string, args ...any) |
| + // Handler decides the response to a message. The ThreadCtx gives it the |
| + // thread's services (history, typing, the model brain); return ok=false to |
| + // stay silent. |
| + Handler func(*ThreadCtx, Incoming) (Reply, bool) |
| + // AIKey is the ANTHROPIC_API_KEY. Set, the runtime self-declares kind "ai" |
| + // and the model brain (brain.go) is live; empty, the bot is deterministic |
| + // and badges "bot". |
| + AIKey string |
| + // Ready, if set, is called once each time the runtime finishes connecting — |
| + // right before it starts reading frames, so a supervisor (or a test) knows |
| + // the bot is live and will receive broadcasts. |
| + Ready func() |
| + Logf func(format string, args ...any) |
| client *http.Client |
| + ai *anthropicClient |
| keys map[string]threadEntry |
| cursors map[string]int64 // threadId -> last message id we've processed |
| agents map[string]bool // roster labels of ALL agents — never reply to one |
| mention *regexp.Regexp |
| + bare *regexp.Regexp // bare-name mention, for listen mode "name" |
| + |
| + // Console-managed config (loadConfig, re-fetched each connect). |
| + persona string |
| + model string |
| + docs string |
| + listen string |
| wmu sync.Mutex // coder/websocket allows one concurrent writer |
| conn *websocket.Conn |
| } |
| +// ThreadCtx hands a Handler the thread's services: decrypted history, the |
| +// typing indicator, and the model brain — everything a reply path needs |
| +// beyond the triggering message itself. |
| +type ThreadCtx struct { |
| + rt *Runtime |
| + ID string |
| + Kind string // "dm" or "group" |
| + key []byte |
| +} |
| + |
| +// HistoryMsg is one decrypted message from the thread's recent history. |
| +type HistoryMsg struct { |
| + ID int64 |
| + Name string |
| + Text string |
| + Mine bool // authored by this bot (by payload name) |
| + ActionCount int // chips the message carried (history truthfulness — brain.go) |
| +} |
| + |
| +// History returns the thread's latest messages, oldest first, decrypted. |
| +// Undecryptable or non-chat payloads are skipped. |
| +func (tc *ThreadCtx) History(limit int) ([]HistoryMsg, error) { |
| + var ml struct { |
| + Messages []struct { |
| + ID int64 `json:"id"` |
| + Blob string `json:"blob"` |
| + } `json:"messages"` |
| + } |
| + path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(tc.ID), limit) |
| + if err := tc.rt.get(path, &ml); err != nil { |
| + return nil, err |
| + } |
| + var out []HistoryMsg |
| + for _, m := range ml.Messages { |
| + var p struct { |
| + Type string `json:"type"` |
| + Name string `json:"name"` |
| + Text string `json:"text"` |
| + Actions []map[string]any `json:"actions"` |
| + } |
| + if e2ee.DecryptJSON(tc.key, m.Blob, &p) != nil || p.Type != "" { |
| + continue |
| + } |
| + out = append(out, HistoryMsg{ |
| + ID: m.ID, Name: p.Name, Text: p.Text, |
| + Mine: p.Name == tc.rt.Session.Name, ActionCount: len(p.Actions), |
| + }) |
| + } |
| + return out, nil |
| +} |
| + |
| +// Typing sends a best-effort "…is typing" beat (expiry seconds; 0 clears it |
| +// now). Same relay-only ephemeral chat.typing shape the live chat engine uses. |
| +func (tc *ThreadCtx) Typing(expiry int) { |
| + blob, err := e2ee.EncryptJSON(tc.key, map[string]any{"type": "chat.typing", "name": tc.rt.Session.Name, "expiry": expiry}) |
| + if err != nil { |
| + return |
| + } |
| + tc.rt.send(map[string]any{"type": "msg", "thread_id": tc.ID, "ephemeral": true, "blob": blob}) |
| +} |
| + |
| +// ModelReply asks the model brain for a conversational reply (recipes reach it |
| +// via the "model" action). ok=false when there's no API key, the call fails, |
| +// or the reply is empty — the caller stays silent or falls through. |
| +func (tc *ThreadCtx) ModelReply() (Reply, bool) { return tc.rt.modelReply(tc) } |
| + |
| // Incoming is one decrypted chat message. The Runtime hands the Handler every |
| // person-authored message it can read (that's what lets watcher recipes exist); |
| // Triggered says whether the bot was actually addressed — a DM, or a group |
| @@ -80,8 +160,9 @@ func (r *Runtime) Run(ctx context.Context) error { |
| r.cursors = map[string]int64{} |
| r.agents = map[string]bool{} |
| r.mention = regexp.MustCompile(`(?i)(^|[^a-z0-9])@` + regexp.QuoteMeta(r.Session.Name) + `\b`) |
| - if r.Kind == "" { |
| - r.Kind = "bot" |
| + r.bare = regexp.MustCompile(`(?i)(^|[^a-z0-9])` + regexp.QuoteMeta(r.Session.Name) + `\b`) |
| + if r.AIKey != "" { |
| + r.ai = newAnthropicClient(r.AIKey) |
| } |
| backoff := time.Second |
| for { |
| @@ -117,11 +198,12 @@ func (r *Runtime) connectOnce(ctx context.Context) error { |
| r.conn = conn |
| defer func() { r.conn = nil }() |
| - // Same setup as the Node runtime's ws.on("open"): learn the roster's bots, |
| - // unwrap thread keys, declare what we are, then backfill anything missed. |
| - // Order matters only in that declareKind is a visible "connected and caught |
| - // up to here" signal; the cursor gate makes catchUp vs live frames safe in |
| - // any interleaving. |
| + // Same setup as the Node runtime's ws.on("open"): pull console config, |
| + // learn the roster's bots, unwrap thread keys, declare what we are, then |
| + // backfill anything missed. Order matters only in that declareKind is a |
| + // visible "connected and caught up to here" signal; the cursor gate makes |
| + // catchUp vs live frames safe in any interleaving. |
| + r.loadConfig() |
| r.loadAgents() |
| r.loadThreads() |
| r.declareKind() |
| @@ -144,6 +226,9 @@ func (r *Runtime) connectOnce(ctx context.Context) error { |
| } |
| }() |
| + if r.Ready != nil { |
| + r.Ready() |
| + } |
| for { |
| _, data, err := conn.Read(ctx) |
| if err != nil { |
| @@ -227,12 +312,13 @@ func (r *Runtime) processMsg(threadID string, id int64, blob string) { |
| if r.Handler == nil { |
| return |
| } |
| - rep, ok := r.Handler(Incoming{ |
| + tc := &ThreadCtx{rt: r, ID: threadID, Kind: entry.kind, key: entry.key} |
| + rep, ok := r.Handler(tc, Incoming{ |
| ThreadID: threadID, |
| Kind: entry.kind, |
| Sender: p.Name, |
| Text: p.Text, |
| - Triggered: r.triggered(entry.kind, p.Text), |
| + Triggered: r.triggered(tc, p.Text), |
| }) |
| if !ok { |
| return |
| @@ -240,14 +326,22 @@ func (r *Runtime) processMsg(threadID string, id int64, blob string) { |
| r.postReply(threadID, entry.key, rep) |
| } |
| -// triggered: DMs always address the bot; groups only on an explicit @Name. The |
| -// Node runtime's listen-mode "name" (bare-name + classifier) needs the model |
| -// brain, so it arrives with the model layer. |
| -func (r *Runtime) triggered(kind, text string) bool { |
| - if kind == "dm" { |
| +// triggered decides whether the bot was addressed. DMs always; groups on an |
| +// explicit @Name. Listen mode "name" (console config) also counts a bare-name |
| +// mention — but a bare name is ambiguous (people talk *about* a bot as often |
| +// as *to* it), so it must additionally pass the model classifier, which means |
| +// the mode does nothing without an API key. |
| +func (r *Runtime) triggered(tc *ThreadCtx, text string) bool { |
| + if tc.Kind == "dm" { |
| + return true |
| + } |
| + if r.mention.MatchString(text) { |
| return true |
| } |
| - return r.mention.MatchString(text) |
| + if r.listen == "name" && r.ai != nil && r.bare.MatchString(text) { |
| + return r.isAddressed(tc) |
| + } |
| + return false |
| } |
| // postReply encrypts and sends one reply payload — the same wire shape the Node |
| @@ -382,10 +476,15 @@ func (r *Runtime) catchUp() { |
| } |
| } |
| -// declareKind self-reports what we are so the UI badges us honestly. Re-declared |
| -// on every connect, best-effort. |
| +// declareKind self-reports what we are so the UI badges us honestly: "ai" |
| +// when the model brain is available, else "bot" (deterministic). Re-declared |
| +// on every connect, so toggling the key flips the badge with no admin action. |
| func (r *Runtime) declareKind() { |
| - body, _ := json.Marshal(map[string]string{"token": r.Session.Token, "kind": r.Kind}) |
| + kind := "bot" |
| + if r.ai != nil { |
| + kind = "ai" |
| + } |
| + body, _ := json.Marshal(map[string]string{"token": r.Session.Token, "kind": kind}) |
| resp, err := r.client.Post(r.Base+"/api/bot-config", "application/json", strings.NewReader(string(body))) |
| if err != nil { |
| r.logf("declareKind: %v", err) |