fix: inject group activation in system prompt
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 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 "<subject>". 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)
|
## 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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user