eleven / ocho Public

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>
Paul Campbell 1af95159879bda7a73c16433489e1be64757be02 parent 3f9e580
4 files changed, +58 −13
  • agent/context.mjs +10 −0
  • agent/runtime.mjs +13 −11
  • internal/bot/brain.go +14 −2
  • internal/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)
+ }
+ }
+}