fix: inject group activation in system prompt

This commit is contained in:
Peter Steinberger
2025-12-23 13:32:07 +00:00
parent 96d57a18ee
commit cba12a1abd
7 changed files with 81 additions and 7 deletions

View File

@@ -16,6 +16,7 @@
- Gateway auth no longer supports PAM/system mode; use token or shared password. - Gateway auth no longer supports PAM/system mode; use token or shared password.
- Tailscale Funnel now requires password auth (no token-only public exposure). - 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 `/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`. - Canvas defaults/A2UI auto-nav aligned; debug status overlay centered; redundant await removed in `CanvasManager`.
- Gateway launchd loop fixed by removing redundant `kickstart -k`. - Gateway launchd loop fixed by removing redundant `kickstart -k`.
- CLI now hints when Peekaboo is unauthorized. - CLI now hints when Peekaboo is unauthorized.

View File

@@ -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]`. - 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. - 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. - 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 "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isnt available we still tell the agent its 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 "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isnt available we still tell the agent its a group chat.
## Config for Clawd UK (+447700900123) ## 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: Add a `groupChat` block to `~/.clawdis/clawdis.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body:

View File

@@ -267,6 +267,7 @@ export async function runEmbeddedPiAgent(params: {
data: Record<string, unknown>; data: Record<string, unknown>;
}) => void; }) => void;
enqueue?: typeof enqueueCommand; enqueue?: typeof enqueueCommand;
extraSystemPrompt?: string;
}): Promise<EmbeddedPiRunResult> { }): Promise<EmbeddedPiRunResult> {
const enqueue = params.enqueue ?? enqueueCommand; const enqueue = params.enqueue ?? enqueueCommand;
return enqueue(async () => { return enqueue(async () => {
@@ -326,6 +327,7 @@ export async function runEmbeddedPiAgent(params: {
appendPrompt: buildAgentSystemPromptAppend({ appendPrompt: buildAgentSystemPromptAppend({
workspaceDir: resolvedWorkspace, workspaceDir: resolvedWorkspace,
defaultThinkLevel: params.thinkLevel, defaultThinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
}), }),
contextFiles, contextFiles,
skills: promptSkills, skills: promptSkills,

View File

@@ -3,13 +3,16 @@ import type { ThinkLevel } from "../auto-reply/thinking.js";
export function buildAgentSystemPromptAppend(params: { export function buildAgentSystemPromptAppend(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
}) { }) {
const thinkHint = const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off" params.defaultThinkLevel && params.defaultThinkLevel !== "off"
? `Default thinking level: ${params.defaultThinkLevel}.` ? `Default thinking level: ${params.defaultThinkLevel}.`
: "Default thinking level: off."; : "Default thinking level: off.";
return [ const extraSystemPrompt = params.extraSystemPrompt?.trim();
const lines = [
"You are Clawd, a personal assistant running inside Clawdis.", "You are Clawd, a personal assistant running inside Clawdis.",
"", "",
"## Tooling", "## 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.", "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.", "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", "## Heartbeats",
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK", "HEARTBEAT_OK",
@@ -42,7 +52,9 @@ export function buildAgentSystemPromptAppend(params: {
"", "",
"## Runtime", "## Runtime",
thinkHint, thinkHint,
] );
return lines
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
} }

View File

@@ -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 () => { it("runs a greeting prompt for a bare /new", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -563,6 +563,7 @@ export async function getReplyFromConfig(
} }
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.groupActivation = activationCommand.mode; sessionEntry.groupActivation = activationCommand.mode;
sessionEntry.groupActivationNeedsSystemIntro = true;
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
@@ -648,8 +649,11 @@ export async function getReplyFromConfig(
await startTypingLoop(); await startTypingLoop();
const isFirstTurnInSession = isNewSession || !systemSent; const isFirstTurnInSession = isNewSession || !systemSent;
const shouldInjectGroupIntro =
sessionCtx.ChatType === "group" &&
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro);
const groupIntro = const groupIntro =
isFirstTurnInSession && sessionCtx.ChatType === "group" shouldInjectGroupIntro
? (() => { ? (() => {
const activation = const activation =
normalizeGroupActivation(sessionEntry?.groupActivation) ?? 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." ? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
: ""; : "";
let prefixedBodyBase = baseBodyFinal; let prefixedBodyBase = baseBodyFinal;
if (groupIntro) {
prefixedBodyBase = `${groupIntro}\n\n${prefixedBodyBase}`;
}
if (abortedHint) { if (abortedHint) {
prefixedBodyBase = `${abortedHint}\n\n${prefixedBodyBase}`; prefixedBodyBase = `${abortedHint}\n\n${prefixedBodyBase}`;
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {
@@ -875,6 +876,7 @@ export async function getReplyFromConfig(
config: cfg, config: cfg,
skillsSnapshot, skillsSnapshot,
prompt: commandBody, prompt: commandBody,
extraSystemPrompt: groupIntro || undefined,
provider, provider,
model, model,
thinkLevel: resolvedThinkLevel, thinkLevel: resolvedThinkLevel,
@@ -898,6 +900,19 @@ export async function getReplyFromConfig(
: undefined, : 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 ?? []; const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return undefined; if (payloadArray.length === 0) return undefined;

View File

@@ -18,6 +18,7 @@ export type SessionEntry = {
thinkingLevel?: string; thinkingLevel?: string;
verboseLevel?: string; verboseLevel?: string;
groupActivation?: "mention" | "always"; groupActivation?: "mention" | "always";
groupActivationNeedsSystemIntro?: boolean;
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
totalTokens?: number; totalTokens?: number;