WhatsApp: add group and direct system prompt support

This commit is contained in:
Bluetegu
2026-04-01 17:19:14 +03:00
committed by Omar Shahine
parent 06a6dd5a6b
commit f6a1b733dc
11 changed files with 318 additions and 1 deletions

View File

@@ -408,6 +408,10 @@ The agent system prompt includes a group intro on the first turn of a new group
- List chats: `imsg chats --limit 20`.
- Group replies always go back to the same `chat_id`.
## WhatsApp system prompts
See [WhatsApp](/channels/whatsapp#system-prompts) for the canonical WhatsApp system prompt rules, including group and direct prompt resolution, wildcard behavior, and account override semantics.
## WhatsApp specifics
See [Group messages](/channels/group-messages) for WhatsApp-only behavior (history injection, mention handling details).

View File

@@ -465,6 +465,75 @@ Behavior notes:
</Accordion>
</AccordionGroup>
## System prompts
WhatsApp supports Telegram-style system prompts for groups and direct chats via the `groups` and `direct` maps.
Resolution hierarchy for group messages:
The effective `groups` map is determined first: if the account defines its own `groups`, it fully replaces the root `groups` map (no deep merge). Prompt lookup then runs on the resulting single map:
1. **Group-specific system prompt** (`groups["<groupId>"].systemPrompt`): used if the specific group entry defines a `systemPrompt`.
2. **Group wildcard system prompt** (`groups["*"].systemPrompt`): used when the specific group entry is absent or defines no `systemPrompt`.
Resolution hierarchy for direct messages:
The effective `direct` map is determined first: if the account defines its own `direct`, it fully replaces the root `direct` map (no deep merge). Prompt lookup then runs on the resulting single map:
1. **Direct-specific system prompt** (`direct["<peerId>"].systemPrompt`): used if the specific peer entry defines a `systemPrompt`.
2. **Direct wildcard system prompt** (`direct["*"].systemPrompt`): used when the specific peer entry is absent or defines no `systemPrompt`.
Note: `dms` remains the lightweight per-DM history override bucket (`dms.<id>.historyLimit`); prompt overrides live under `direct`.
**Difference from Telegram multi-account behavior:** In Telegram, root `groups` is intentionally suppressed for all accounts in a multi-account setup — even accounts that define no `groups` of their own — to prevent a bot from receiving group messages for groups it does not belong to. WhatsApp does not apply this guard: root `groups` and root `direct` are always inherited by accounts that define no account-level override, regardless of how many accounts are configured. In a multi-account WhatsApp setup, if you want per-account group or direct prompts, define the full map under each account explicitly rather than relying on root-level defaults.
Important behavior:
- `channels.whatsapp.groups` is both a per-group config map and the chat-level group allowlist. At either the root or account scope, `groups["*"]` means "all groups are admitted" for that scope.
- Only add a wildcard group `systemPrompt` when you already want that scope to admit all groups. If you still want only a fixed set of group IDs to be eligible, do not use `groups["*"]` for the prompt default. Instead, repeat the prompt on each explicitly allowlisted group entry.
- Group admission and sender authorization are separate checks. `groups["*"]` widens the set of groups that can reach group handling, but it does not by itself authorize every sender in those groups. Sender access is still controlled separately by `channels.whatsapp.groupPolicy` and `channels.whatsapp.groupAllowFrom`.
- `channels.whatsapp.direct` does not have the same side effect for DMs. `direct["*"]` only provides a default direct-chat config after a DM is already admitted by `dmPolicy` plus `allowFrom` or pairing-store rules.
Example:
```json5
{
channels: {
whatsapp: {
groups: {
// Use only if all groups should be admitted at the root scope.
// Applies to all accounts that do not define their own groups map.
"*": { systemPrompt: "Default prompt for all groups." },
},
direct: {
// Applies to all accounts that do not define their own direct map.
"*": { systemPrompt: "Default prompt for all direct chats." },
},
accounts: {
work: {
groups: {
// This account defines its own groups, so root groups are fully
// replaced. To keep a wildcard, define "*" explicitly here too.
"120363406415684625@g.us": {
requireMention: false,
systemPrompt: "Focus on project management.",
},
// Use only if all groups should be admitted in this account.
"*": { systemPrompt: "Default prompt for work groups." },
},
direct: {
// This account defines its own direct map, so root direct entries are
// fully replaced. To keep a wildcard, define "*" explicitly here too.
"+15551234567": { systemPrompt: "Prompt for a specific work direct chat." },
"*": { systemPrompt: "Default prompt for work direct chats." },
},
},
},
},
},
}
```
## Configuration reference pointers
Primary reference:
@@ -478,6 +547,7 @@ High-signal WhatsApp fields:
- multi-account: `accounts.<id>.enabled`, `accounts.<id>.authDir`, account-level overrides
- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`
- session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit`
- prompts: `groups.<id>.systemPrompt`, `groups["*"].systemPrompt`, `direct.<id>.systemPrompt`, `direct["*"].systemPrompt`
## Related

View File

@@ -36,6 +36,7 @@ export type ResolvedWhatsAppAccount = {
ackReaction?: WhatsAppAccountConfig["ackReaction"];
reactionLevel?: WhatsAppAccountConfig["reactionLevel"];
groups?: WhatsAppAccountConfig["groups"];
direct?: WhatsAppAccountConfig["direct"];
debounceMs?: number;
};
@@ -150,6 +151,7 @@ export function resolveWhatsAppAccount(params: {
ackReaction: merged.ackReaction,
reactionLevel: merged.reactionLevel,
groups: merged.groups,
direct: merged.direct,
debounceMs: merged.debounceMs,
};
}

View File

@@ -194,6 +194,44 @@ describe("whatsapp inbound dispatch", () => {
expect(ctx.To).toBe("+2000");
});
it("passes groupSystemPrompt into GroupSystemPrompt for group chats", () => {
const ctx = buildWhatsAppInboundContext({
combinedBody: "hi",
conversationId: "123@g.us",
groupSystemPrompt: "Specific group prompt",
msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }),
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
sender: { e164: "+15550002222" },
});
expect(ctx.GroupSystemPrompt).toBe("Specific group prompt");
});
it("passes groupSystemPrompt into GroupSystemPrompt for direct chats", () => {
const ctx = buildWhatsAppInboundContext({
combinedBody: "hi",
conversationId: "+1555",
groupSystemPrompt: "Specific direct prompt",
msg: makeMsg({ from: "+1555", chatType: "direct" }),
route: makeRoute({ sessionKey: "agent:main:whatsapp:direct:+1555" }),
sender: { e164: "+1555" },
});
expect(ctx.GroupSystemPrompt).toBe("Specific direct prompt");
});
it("omits GroupSystemPrompt when groupSystemPrompt is not provided", () => {
const ctx = buildWhatsAppInboundContext({
combinedBody: "hi",
conversationId: "123@g.us",
msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }),
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
sender: { e164: "+15550002222" },
});
expect(ctx.GroupSystemPrompt).toBeUndefined();
});
it("defaults responsePrefix to identity name in self-chats when unset", () => {
const responsePrefix = resolveWhatsAppResponsePrefix({
cfg: {

View File

@@ -87,6 +87,7 @@ export function buildWhatsAppInboundContext(params: {
conversationId: string;
groupHistory?: GroupHistoryEntry[];
groupMemberRoster?: Map<string, string>;
groupSystemPrompt?: string;
msg: WebInboundMsg;
route: ReturnType<typeof resolveAgentRoute>;
sender: SenderContext;
@@ -132,6 +133,7 @@ export function buildWhatsAppInboundContext(params: {
SenderE164: params.sender.e164,
CommandAuthorized: params.commandAuthorized,
WasMentioned: params.msg.wasMentioned,
GroupSystemPrompt: params.groupSystemPrompt,
...(params.msg.location ? toLocationContext(params.msg.location) : {}),
Provider: "whatsapp",
Surface: "whatsapp",

View File

@@ -1,3 +1,7 @@
import {
resolveWhatsAppDirectSystemPrompt,
resolveWhatsAppGroupSystemPrompt,
} from "../../system-prompt.js";
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
import {
resolveWhatsAppCommandAuthorized,
@@ -227,12 +231,25 @@ export async function processMessage(params: {
pipelineResponsePrefix: replyPipeline.responsePrefix,
});
// Resolve combined conversation system prompt using the group or direct surface.
const conversationSystemPrompt =
params.msg.chatType === "group"
? resolveWhatsAppGroupSystemPrompt({
accountConfig: account,
groupId: conversationId,
})
: resolveWhatsAppDirectSystemPrompt({
accountConfig: account,
peerId: dmRouteTarget ?? params.msg.from,
});
const ctxPayload = buildWhatsAppInboundContext({
combinedBody,
commandAuthorized,
conversationId,
groupHistory: visibleGroupHistory,
groupMemberRoster: params.groupMemberNames.get(params.groupHistoryKey),
groupSystemPrompt: conversationSystemPrompt,
msg: params.msg,
route: params.route,
sender: {

View File

@@ -0,0 +1,29 @@
export function resolveWhatsAppGroupSystemPrompt(params: {
accountConfig?: { groups?: Record<string, { systemPrompt?: string }> } | null;
groupId?: string | null;
}): string | undefined {
if (!params.groupId) {
return undefined;
}
const groups = params.accountConfig?.groups;
return (
groups?.[params.groupId]?.systemPrompt?.trim() ||
groups?.["*"]?.systemPrompt?.trim() ||
undefined
);
}
export function resolveWhatsAppDirectSystemPrompt(params: {
accountConfig?: { direct?: Record<string, { systemPrompt?: string }> } | null;
peerId?: string | null;
}): string | undefined {
if (!params.peerId) {
return undefined;
}
const direct = params.accountConfig?.direct;
return (
direct?.[params.peerId]?.systemPrompt?.trim() ||
direct?.["*"]?.systemPrompt?.trim() ||
undefined
);
}

View File

@@ -15154,6 +15154,24 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
additionalProperties: false,
},
},
systemPrompt: {
type: "string",
},
},
additionalProperties: false,
},
},
direct: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
systemPrompt: {
type: "string",
},
},
additionalProperties: false,
},
@@ -15405,6 +15423,24 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
additionalProperties: false,
},
},
systemPrompt: {
type: "string",
},
},
additionalProperties: false,
},
},
direct: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
systemPrompt: {
type: "string",
},
},
additionalProperties: false,
},

View File

@@ -25,6 +25,13 @@ export type WhatsAppGroupConfig = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
/** Optional system prompt for this group. */
systemPrompt?: string;
};
export type WhatsAppDirectConfig = {
/** Optional system prompt for this direct chat. */
systemPrompt?: string;
};
export type WhatsAppAckReactionConfig = {
@@ -68,7 +75,7 @@ type WhatsAppSharedConfig = {
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
/** Per-DM history overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
@@ -81,6 +88,8 @@ type WhatsAppSharedConfig = {
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
groups?: Record<string, WhatsAppGroupConfig>;
/** Per-direct-chat prompt overrides keyed by user ID or `*` wildcard. */
direct?: Record<string, WhatsAppDirectConfig>;
/** Acknowledgment reaction sent immediately upon message receipt. */
ackReaction?: WhatsAppAckReactionConfig;
/**

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { WhatsAppConfigSchema, WhatsAppAccountSchema } from "./zod-schema.providers-whatsapp.js";
describe("WhatsApp prompt config Zod validation", () => {
it("validates group-level systemPrompt", () => {
const config = {
groups: {
"123@g.us": {
systemPrompt: "This is a work group",
},
},
};
const result = WhatsAppConfigSchema.safeParse(config);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.groups?.["123@g.us"]?.systemPrompt).toBe("This is a work group");
}
});
it("validates direct-level systemPrompt", () => {
const config = {
direct: {
"+15551234567": {
systemPrompt: "This is a VIP direct chat",
},
},
};
const result = WhatsAppConfigSchema.safeParse(config);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.direct?.["+15551234567"]?.systemPrompt).toBe("This is a VIP direct chat");
}
});
it("validates combined group and direct prompt surfaces", () => {
const config = {
groups: {
"*": {
systemPrompt: "Default group prompt",
},
},
direct: {
"+15551234567": {
systemPrompt: "Direct VIP",
},
},
accounts: {
work: {
groups: {
"456@g.us": {
systemPrompt: "Project team",
},
},
direct: {
"*": {
systemPrompt: "Work direct default",
},
},
},
},
};
const result = WhatsAppConfigSchema.safeParse(config);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.groups?.["*"]?.systemPrompt).toBe("Default group prompt");
expect(result.data.direct?.["+15551234567"]?.systemPrompt).toBe("Direct VIP");
expect(result.data.accounts?.work?.groups?.["456@g.us"]?.systemPrompt).toBe("Project team");
expect(result.data.accounts?.work?.direct?.["*"]?.systemPrompt).toBe("Work direct default");
}
});
it("validates WhatsAppAccountSchema directly", () => {
const accountConfig = {
name: "Personal Account",
groups: {
"family@g.us": {
systemPrompt: "Keep responses family-friendly",
},
},
direct: {
"+15557654321": {
systemPrompt: "Keep responses concise",
},
},
};
const result = WhatsAppAccountSchema.safeParse(accountConfig);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.groups?.["family@g.us"]?.systemPrompt).toBe(
"Keep responses family-friendly",
);
expect(result.data.direct?.["+15557654321"]?.systemPrompt).toBe("Keep responses concise");
}
});
});

View File

@@ -22,12 +22,22 @@ const WhatsAppGroupEntrySchema = z
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
systemPrompt: z.string().optional(),
})
.strict()
.optional();
const WhatsAppGroupsSchema = z.record(z.string(), WhatsAppGroupEntrySchema).optional();
const WhatsAppDirectEntrySchema = z
.object({
systemPrompt: z.string().optional(),
})
.strict()
.optional();
const WhatsAppDirectSchema = z.record(z.string(), WhatsAppDirectEntrySchema).optional();
const WhatsAppAckReactionSchema = z
.object({
emoji: z.string().optional(),
@@ -65,6 +75,7 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) {
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: WhatsAppGroupsSchema,
direct: WhatsAppDirectSchema,
ackReaction: WhatsAppAckReactionSchema,
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
debounceMs: params.useDefaults