From cba12a1abda12d9b0d8d30b74813d3c0ba0cf92a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 23 Dec 2025 13:32:07 +0000 Subject: [PATCH] fix: inject group activation in system prompt --- CHANGELOG.md | 1 + docs/group-messages.md | 2 +- src/agents/pi-embedded-runner.ts | 2 ++ src/agents/system-prompt.ts | 16 ++++++++-- src/auto-reply/reply.triggers.test.ts | 43 +++++++++++++++++++++++++++ src/auto-reply/reply.ts | 23 +++++++++++--- src/config/sessions.ts | 1 + 7 files changed, 81 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3864f8af3..b59e1186d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Gateway auth no longer supports PAM/system mode; use token or shared password. - Tailscale Funnel now requires password auth (no token-only public exposure). - Group `/new` resets now work with @mentions so activation guidance appears on fresh sessions. +- Group chat activation context is now injected into the system prompt at session start (and after activation changes), including /new greetings. - Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`. - Gateway launchd loop fixed by removing redundant `kickstart -k`. - CLI now hints when Peekaboo is unauthorized. diff --git a/docs/group-messages.md b/docs/group-messages.md index bb6498eef..191827d4b 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -14,7 +14,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. - Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. -- New session primer: on the first turn of a group session we now prepend a short blurb to the model like `You are replying inside the WhatsApp group "". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat. +- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat. ## Config for Clawd UK (+447700900123) Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body: diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 3767785c7..04a590d7d 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -267,6 +267,7 @@ export async function runEmbeddedPiAgent(params: { data: Record; }) => void; enqueue?: typeof enqueueCommand; + extraSystemPrompt?: string; }): Promise { const enqueue = params.enqueue ?? enqueueCommand; return enqueue(async () => { @@ -326,6 +327,7 @@ export async function runEmbeddedPiAgent(params: { appendPrompt: buildAgentSystemPromptAppend({ workspaceDir: resolvedWorkspace, defaultThinkLevel: params.thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, }), contextFiles, skills: promptSkills, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 8cd22d26c..cbf61b4e1 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -3,13 +3,16 @@ import type { ThinkLevel } from "../auto-reply/thinking.js"; export function buildAgentSystemPromptAppend(params: { workspaceDir: string; defaultThinkLevel?: ThinkLevel; + extraSystemPrompt?: string; }) { const thinkHint = params.defaultThinkLevel && params.defaultThinkLevel !== "off" ? `Default thinking level: ${params.defaultThinkLevel}.` : "Default thinking level: off."; - return [ + const extraSystemPrompt = params.extraSystemPrompt?.trim(); + + const lines = [ "You are Clawd, a personal assistant running inside Clawdis.", "", "## Tooling", @@ -35,6 +38,13 @@ export function buildAgentSystemPromptAppend(params: { "Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", "Clawdis handles message transport automatically; respond normally and your reply will be delivered to the current chat.", "", + ]; + + if (extraSystemPrompt) { + lines.push("## Group Chat Context", extraSystemPrompt, ""); + } + + lines.push( "## Heartbeats", 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', "HEARTBEAT_OK", @@ -42,7 +52,9 @@ export function buildAgentSystemPromptAppend(params: { "", "## Runtime", thinkHint, - ] + ); + + return lines .filter(Boolean) .join("\n"); } diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index ab9dde8d4..f805591ce 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -142,6 +142,49 @@ describe("trigger handling", () => { }); }); + it("injects group activation context into the system prompt", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "hello group", + From: "123@g.us", + To: "+2000", + ChatType: "group", + SenderE164: "+2000", + GroupSubject: "Test Group", + GroupMembers: "Alice (+1), Bob (+2)", + }, + {}, + { + inbound: { + allowFrom: ["*"], + workspace: join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { store: join(home, "sessions.json") }, + groupChat: { requireMention: false }, + }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const extra = + vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.extraSystemPrompt ?? + ""; + expect(extra).toContain("Test Group"); + expect(extra).toContain("Activation: always-on"); + }); + }); + it("runs a greeting prompt for a bare /new", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 413698e76..f4f81a3f2 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -563,6 +563,7 @@ export async function getReplyFromConfig( } if (sessionEntry && sessionStore && sessionKey) { sessionEntry.groupActivation = activationCommand.mode; + sessionEntry.groupActivationNeedsSystemIntro = true; sessionEntry.updatedAt = Date.now(); sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); @@ -648,8 +649,11 @@ export async function getReplyFromConfig( await startTypingLoop(); const isFirstTurnInSession = isNewSession || !systemSent; + const shouldInjectGroupIntro = + sessionCtx.ChatType === "group" && + (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro); const groupIntro = - isFirstTurnInSession && sessionCtx.ChatType === "group" + shouldInjectGroupIntro ? (() => { const activation = normalizeGroupActivation(sessionEntry?.groupActivation) ?? @@ -713,9 +717,6 @@ export async function getReplyFromConfig( ? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification." : ""; let prefixedBodyBase = baseBodyFinal; - if (groupIntro) { - prefixedBodyBase = `${groupIntro}\n\n${prefixedBodyBase}`; - } if (abortedHint) { prefixedBodyBase = `${abortedHint}\n\n${prefixedBodyBase}`; if (sessionEntry && sessionStore && sessionKey) { @@ -875,6 +876,7 @@ export async function getReplyFromConfig( config: cfg, skillsSnapshot, prompt: commandBody, + extraSystemPrompt: groupIntro || undefined, provider, model, thinkLevel: resolvedThinkLevel, @@ -898,6 +900,19 @@ export async function getReplyFromConfig( : undefined, }); + if ( + shouldInjectGroupIntro && + sessionEntry && + sessionStore && + sessionKey && + sessionEntry.groupActivationNeedsSystemIntro + ) { + sessionEntry.groupActivationNeedsSystemIntro = false; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + const payloadArray = runResult.payloads ?? []; if (payloadArray.length === 0) return undefined; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 54afc10d5..c17602668 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -18,6 +18,7 @@ export type SessionEntry = { thinkingLevel?: string; verboseLevel?: string; groupActivation?: "mention" | "always"; + groupActivationNeedsSystemIntro?: boolean; inputTokens?: number; outputTokens?: number; totalTokens?: number;