bot: scrub the note-to-self history annotation from posted replies (#826)
buildContext appends "[note to self: this reply attached N button(s) via tool calls]" to the model's own past turns so history stays truthful about chips — but the model reads those turns as its own words and, despite the system-prompt rule, eventually imitates the pattern into a live reply (seen on team.elevenmessenger.com: the bracket line posted verbatim under "Tap to see your open issues"). The note is for the model, never for members: scrub it from reply text before posting, in both the Go sidecar and the Node reference (parity).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+58
−13
agent/context.mjs+10 −0agent/runtime.mjs+13 −11internal/bot/brain.go+14 −2internal/bot/brain_scrub_test.go+21 −0
diff --git a/agent/context.mjs b/agent/context.mjs| index 6445495..59b5231 100644 |
| --- a/agent/context.mjs |
| +++ b/agent/context.mjs |
| @@ -15,3 +15,13 @@ export function trimContext(messages) { |
| while (out.length && out[out.length - 1].role === "assistant") out.pop(); |
| return out; |
| } |
| + |
| +// scrubSelfNotes removes the "[note to self: …]" history annotation from a |
| +// reply about to POST. The note is appended to past assistant turns (see |
| +// buildContext in runtime.mjs) so the model knows which of its old replies |
| +// carried buttons — but the model reads those turns as its own words and, |
| +// despite the system-prompt rule, eventually imitates the pattern into a live |
| +// reply. The note is for the model, never for members. |
| +export function scrubSelfNotes(text) { |
| + return (text || "").replace(/\s*\[note to self:[^\]]*\]/g, "").trim(); |
| +} |
diff --git a/agent/runtime.mjs b/agent/runtime.mjs| index ad2f6c7..6e0096e 100644 |
| --- a/agent/runtime.mjs |
| +++ b/agent/runtime.mjs |
| @@ -11,7 +11,7 @@ import { importKey, encrypt, decrypt, unwrapThreadKey, openSealed, sealForPub } |
| import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME, AI_ENABLED } from "./config.mjs"; |
| import { actionsFromToolUse } from "./actions.mjs"; |
| import { runCommand } from "./commands.mjs"; |
| -import { trimContext } from "./context.mjs"; |
| +import { trimContext, scrubSelfNotes } from "./context.mjs"; |
| import { listDomains, exchangeViaBroker } from "./dnsimple.mjs"; |
| const log = (m) => { |
| @@ -264,11 +264,12 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| ...(tools ? { tools } : {}), |
| messages: context.length ? context : [{ role: "user", content: "Say hello." }], |
| }); |
| - text = r.content |
| - .filter((b) => b.type === "text") |
| - .map((b) => b.text) |
| - .join("") |
| - .trim(); |
| + text = scrubSelfNotes( |
| + r.content |
| + .filter((b) => b.type === "text") |
| + .map((b) => b.text) |
| + .join(""), |
| + ); |
| // Tool calls become one-tap chips on the message; there's no tool_result |
| // round-trip — the "result" is the member tapping (or not tapping) it. |
| if (tools) actions = actionsFromToolUse(r.content); |
| @@ -289,11 +290,12 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t |
| messages: context.length ? context : [{ role: "user", content: "Say hello." }], |
| }); |
| actions = actionsFromToolUse(r2.content); |
| - const t2 = r2.content |
| - .filter((b) => b.type === "text") |
| - .map((b) => b.text) |
| - .join("") |
| - .trim(); |
| + const t2 = scrubSelfNotes( |
| + r2.content |
| + .filter((b) => b.type === "text") |
| + .map((b) => b.text) |
| + .join(""), |
| + ); |
| if (actions.length && t2) text = t2; // keep the original text if the retry was tool-only |
| } |
| if (!text && !actions.length) tag(`empty reply dropped (stop_reason=${r.stop_reason})`); |
diff --git a/internal/bot/brain.go b/internal/bot/brain.go| index e991bfd..0c0703a 100644 |
| --- a/internal/bot/brain.go |
| +++ b/internal/bot/brain.go |
| @@ -423,6 +423,18 @@ func (r *Runtime) charterHomeNote(tc *ThreadCtx) string { |
| return b.String() |
| } |
| +// noteToSelfRE matches the history annotation buildContext appends to past |
| +// assistant turns ("[note to self: this reply attached N button(s) via tool |
| +// calls]"). The model reads those turns as its own words, and despite the |
| +// system-prompt rule it eventually IMITATES the pattern, writing the bracket |
| +// line into a live reply (seen on team.elevenmessenger.com). The note is for |
| +// the model, never for members — scrub it from anything about to post. |
| +var noteToSelfRE = regexp.MustCompile(`\s*\[note to self:[^\]]*\]`) |
| + |
| +func scrubSelfNotes(text string) string { |
| + return strings.TrimSpace(noteToSelfRE.ReplaceAllString(text, "")) |
| +} |
| + |
| // promisedButton catches a reply that PROMISES an action instead of attaching |
| // one: the classic "tap the button" with no tool call, and the fetch-promise |
| // family ("Great! Let me pull up the open issues for you." — the live stall |
| @@ -489,7 +501,7 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| r.logf("claude: %v", err) |
| return Reply{}, false |
| } |
| - text := strings.TrimSpace(resp.text()) |
| + text := scrubSelfNotes(resp.text()) |
| // create_app / create_bot are EXECUTORS, not chips: the sidecar itself runs |
| // the build (appbuild.go / botbuild.go) on its own goroutine so other |
| // threads keep getting replies, and posts the outcome when done. The reply |
| @@ -559,7 +571,7 @@ func (r *Runtime) modelReply(tc *ThreadCtx) (Reply, bool) { |
| }) |
| if err == nil { |
| actions = actionsFromToolUse(resp2.Content) |
| - if t2 := strings.TrimSpace(resp2.text()); len(actions) > 0 && t2 != "" { |
| + if t2 := scrubSelfNotes(resp2.text()); len(actions) > 0 && t2 != "" { |
| text = t2 // keep the original text if the retry was tool-only |
| } |
| } |
diff --git a/internal/bot/brain_scrub_test.go b/internal/bot/brain_scrub_test.go| new file mode 100644 |
| index 0000000..9de677a |
| --- /dev/null |
| +++ b/internal/bot/brain_scrub_test.go |
| @@ -0,0 +1,21 @@ |
| +package bot |
| + |
| +import "testing" |
| + |
| +// TestScrubSelfNotes pins the output scrub: the "[note to self: …]" history |
| +// annotation must never reach a posted reply, however the model weaves it in. |
| +func TestScrubSelfNotes(t *testing.T) { |
| + cases := []struct{ in, want string }{ |
| + {"Tap to see your issues:\n[note to self: this reply attached 1 button(s) via tool calls]", |
| + "Tap to see your issues:"}, |
| + {"[note to self: this reply attached 2 button(s) via tool calls]", ""}, |
| + {"Before [note to self: x] after", "Before after"}, |
| + {"No notes here.", "No notes here."}, |
| + {"A [bracket] that is not a note survives.", "A [bracket] that is not a note survives."}, |
| + } |
| + for _, c := range cases { |
| + if got := scrubSelfNotes(c.in); got != c.want { |
| + t.Errorf("scrubSelfNotes(%q) = %q, want %q", c.in, got, c.want) |
| + } |
| + } |
| +} |