feat(imessage): per-group systemPrompt (parity with other channels) (#79383)

Merged via squash.

Prepared head SHA: 2eecd01ed8
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
This commit is contained in:
Omar Shahine
2026-05-08 21:02:39 -04:00
committed by GitHub
parent 30e870e424
commit 85ebb4c471
8 changed files with 350 additions and 14 deletions

View File

@@ -188,6 +188,7 @@ Docs: https://docs.openclaw.ai
- Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026.
- Plugin SDK: add a generic `api.runtime.llm.complete` host completion helper with runtime-derived caller attribution, config-gated model/agent overrides, session-bound context-engine access, request-scoped config, audit metadata, and normalized usage attribution. (#64294) Thanks @DaevMithran.
- Control UI/exec approvals: highlight parsed shell command fragments that may deserve extra review in approval prompts. (#77153) Thanks @jesse-merhi.
- Channels/iMessage: honor `channels.imessage.groups.<chat_id>.systemPrompt` (and the `groups["*"]` wildcard) by forwarding it as `GroupSystemPrompt` on inbound group turns, mirroring the byte-identical resolver semantic from WhatsApp where defining the key as an empty string on a specific group suppresses the wildcard fallback. Brings iMessage to parity with the per-group `systemPrompt` pattern already supported by Discord, Telegram, IRC, Slack, GoogleChat, and the retired BlueBubbles channel. Fixes #78285. (#79383) Thanks @omarshahine.
### Breaking

View File

@@ -1,4 +1,4 @@
98f80c92fc4fcb37d41470216ae6cd19b094d7f67b0ddc4983eba04aba314fe0 config-baseline.json
d9c4b2035178d3ffe637b751036f12082d4f26761681bb8496b86550565307e8 config-baseline.core.json
ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json
91480b7bb68280f5b762f4352e456b294d673efcb3989874f70f618714985c71 config-baseline.json
7c4f1417784024d6942de993f1b4dcb9f20c82cec7674047d6b351ab1f586fde config-baseline.core.json
d851534e7f7f44b427d7fa82b7ad287349f069461e3569d23583929611821c31 config-baseline.channel.json
7a9ed89a6ff7e578bfcab7828ab660af59e62402a85bfbfc05d5ae3d975e9728 config-baseline.plugin.json

View File

@@ -271,6 +271,37 @@ If SIP-disabled isn't acceptable for your threat model:
Control commands from authorized senders can bypass mention gating in groups.
Per-group `systemPrompt`:
Each entry under `channels.imessage.groups.*` accepts an optional `systemPrompt` string. The value is injected into the agent's system prompt on every turn that handles a message in that group. Resolution mirrors the per-group prompt resolution used by `channels.whatsapp.groups`:
1. **Group-specific system prompt** (`groups["<chat_id>"].systemPrompt`): used when the specific group entry exists in the map **and** its `systemPrompt` key is defined. If `systemPrompt` is an empty string (`""`) the wildcard is suppressed and no system prompt is applied to that group.
2. **Group wildcard system prompt** (`groups["*"].systemPrompt`): used when the specific group entry is absent from the map entirely, or when it exists but defines no `systemPrompt` key.
```json5
{
channels: {
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"*": { systemPrompt: "Use British spelling." },
"8421": {
requireMention: true,
systemPrompt: "This is the on-call rotation chat. Keep replies under 3 sentences.",
},
"9907": {
// explicit suppression: the wildcard "Use British spelling." does not apply here
systemPrompt: "",
},
},
},
},
}
```
Per-group prompts only apply to group messages — direct messages in this channel are unaffected.
</Tab>
<Tab title="Sessions and deterministic replies">

View File

@@ -0,0 +1,252 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, it } from "vitest";
import {
buildIMessageInboundContext,
resolveIMessageInboundDecision,
} from "./inbound-processing.js";
type DecisionParams = Parameters<typeof resolveIMessageInboundDecision>[0];
function buildCfgWithGroups(
groups: Record<string, { requireMention?: boolean; systemPrompt?: string }>,
): OpenClawConfig {
return {
channels: {
imessage: {
groupPolicy: "allowlist",
groups,
},
},
} as unknown as OpenClawConfig;
}
function buildDecisionParams(overrides: Partial<DecisionParams> = {}): DecisionParams {
return {
cfg: overrides.cfg ?? ({} as OpenClawConfig),
accountId: "default",
message: {
id: 1,
sender: "+15555550123",
text: "hi",
is_from_me: false,
is_group: true,
chat_id: 7,
chat_guid: "any;+;chatXYZ",
chat_identifier: "chatXYZ",
created_at: "2026-05-08T03:00:00Z",
} as DecisionParams["message"],
messageText: "hi",
bodyText: "hi",
allowFrom: ["+15555550123"],
groupAllowFrom: ["+15555550123"],
groupPolicy: "allowlist",
dmPolicy: "open",
storeAllowFrom: [],
historyLimit: 0,
groupHistories: new Map(),
echoCache: undefined,
selfChatCache: undefined,
logVerbose: undefined,
...overrides,
};
}
describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
it("captures the per-chat_id systemPrompt on group dispatch decisions", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"7": { systemPrompt: "Keep responses under 3 sentences." },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Keep responses under 3 sentences.");
});
it("falls back to the groups['*'] wildcard systemPrompt", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Default group voice." },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Default group voice.");
});
it("prefers the per-chat_id systemPrompt over the wildcard when both are set", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Default group voice." },
"7": { systemPrompt: "Specific group voice." },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Specific group voice.");
});
it("treats whitespace-only per-chat_id systemPrompt as suppression of the wildcard", () => {
// Mirrors WhatsApp semantic: defining the systemPrompt key on a specific
// group entry (even as whitespace) means "this group has no prompt" and
// suppresses the groups["*"] fallback.
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { systemPrompt: " " },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBeUndefined();
});
it("treats explicit empty-string per-chat_id systemPrompt as suppression of the wildcard", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { systemPrompt: "" },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBeUndefined();
});
it("falls back to the wildcard when the per-chat_id entry has no systemPrompt key at all", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"*": { systemPrompt: "Wildcard." },
"7": { requireMention: true },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.groupSystemPrompt).toBe("Wildcard.");
});
it("does not set groupSystemPrompt on true DM decisions", () => {
// Use a chat_id that does NOT match any configured group entry, and
// route through the DM-shaped message (is_group=false, no chat_id key
// in groups). Without a groupConfig match the path stays a DM and the
// group prompt must not bleed into the ctx.
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"999": { systemPrompt: "Other group." },
}),
message: {
id: 1,
sender: "+15555550123",
text: "hi",
is_from_me: false,
is_group: false,
chat_id: 42,
chat_identifier: "+15555550123",
destination_caller_id: "+15555550456",
created_at: "2026-05-08T03:00:00Z",
} as DecisionParams["message"],
groupPolicy: "open",
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") {
return;
}
expect(decision.isGroup).toBe(false);
expect(decision.groupSystemPrompt).toBeUndefined();
});
});
describe("buildIMessageInboundContext forwards GroupSystemPrompt", () => {
function buildBuildParams(decision: {
isGroup: boolean;
groupSystemPrompt?: string;
}): Parameters<typeof buildIMessageInboundContext>[0] {
return {
cfg: {} as OpenClawConfig,
decision: {
kind: "dispatch",
isGroup: decision.isGroup,
chatId: decision.isGroup ? 7 : undefined,
chatGuid: decision.isGroup ? "any;+;chatXYZ" : "any;-;+15555550123",
chatIdentifier: decision.isGroup ? "chatXYZ" : "+15555550123",
groupId: decision.isGroup ? "7" : undefined,
historyKey: undefined,
sender: "+15555550123",
senderNormalized: "+15555550123",
route: {
accountId: "default",
agentId: "lobster",
channel: "imessage",
sessionKey: "k",
mainSessionKey: "mk",
lastRoutePolicy: "main",
matchedBy: "default",
},
bodyText: "hi",
createdAt: undefined,
replyContext: null,
effectiveWasMentioned: false,
commandAuthorized: false,
effectiveDmAllowFrom: [],
effectiveGroupAllowFrom: [],
groupSystemPrompt: decision.groupSystemPrompt,
} as Parameters<typeof buildIMessageInboundContext>[0]["decision"],
message: {
sender: "+15555550123",
text: "hi",
is_group: decision.isGroup,
chat_id: decision.isGroup ? 7 : undefined,
chat_name: decision.isGroup ? "Test Group" : undefined,
} as Parameters<typeof buildIMessageInboundContext>[0]["message"],
historyLimit: 0,
groupHistories: new Map(),
} as Parameters<typeof buildIMessageInboundContext>[0];
}
it("sets ctxPayload.GroupSystemPrompt for group messages", () => {
const { ctxPayload } = buildIMessageInboundContext(
buildBuildParams({ isGroup: true, groupSystemPrompt: "Be concise." }),
);
expect(ctxPayload.GroupSystemPrompt).toBe("Be concise.");
});
it("leaves ctxPayload.GroupSystemPrompt undefined when no per-group prompt is configured", () => {
const { ctxPayload } = buildIMessageInboundContext(
buildBuildParams({ isGroup: true, groupSystemPrompt: undefined }),
);
expect(ctxPayload.GroupSystemPrompt).toBeUndefined();
});
it("leaves ctxPayload.GroupSystemPrompt undefined for DMs even if a prompt is somehow on decision", () => {
const { ctxPayload } = buildIMessageInboundContext(
buildBuildParams({ isGroup: false, groupSystemPrompt: "should-not-leak" }),
);
expect(ctxPayload.GroupSystemPrompt).toBeUndefined();
});
});

View File

@@ -131,6 +131,31 @@ function hasIMessageEchoMatch(params: {
return false;
}
/**
* Per-group `systemPrompt` resolution. Mirrors `resolveWhatsAppGroupSystemPrompt`
* in `extensions/whatsapp/src/system-prompt.ts`:
*
* 1. If the matched per-`chat_id` entry exists AND defines `systemPrompt` (key
* is present, value is non-null), use it. Trim whitespace; if the trim
* leaves an empty string, return `undefined` and DO NOT fall through to the
* wildcard. This is how operators say "this specific group has no prompt"
* without inheriting from `groups["*"]`.
* 2. Otherwise, return the wildcard `groups["*"].systemPrompt` (trimmed; empty
* after trim → `undefined`).
*/
export function resolveIMessageGroupSystemPrompt(params: {
groupConfig: unknown;
defaultConfig: unknown;
}): string | undefined {
const specific = params.groupConfig as { systemPrompt?: string | null } | undefined;
if (specific != null && specific.systemPrompt != null) {
return specific.systemPrompt.trim() || undefined;
}
const wildcard = (params.defaultConfig as { systemPrompt?: string | null } | undefined)
?.systemPrompt;
return wildcard != null ? wildcard.trim() || undefined : undefined;
}
type IMessageInboundDispatchDecision = {
kind: "dispatch";
isGroup: boolean;
@@ -150,6 +175,10 @@ type IMessageInboundDispatchDecision = {
// Used for allowlist checks for control commands.
effectiveDmAllowFrom: string[];
effectiveGroupAllowFrom: string[];
// Forwarded as ctxPayload.GroupSystemPrompt for group messages. Resolved
// from `channels.imessage.groups.<chat_id>.systemPrompt` (or the `"*"`
// wildcard) at gate time. Always undefined for DMs.
groupSystemPrompt?: string;
};
type IMessageInboundDecision =
@@ -526,6 +555,18 @@ export function resolveIMessageInboundDecision(params: {
return { kind: "drop", reason: "no mention" };
}
// Per-chat_id `systemPrompt` wins; fall back to the `groups["*"]` wildcard
// ONLY when the matched group does not define the key at all. If the matched
// group sets `systemPrompt: ""` the wildcard is suppressed (no prompt is
// applied to that specific group). Mirrors the resolution semantic in
// `extensions/whatsapp/src/system-prompt.ts`.
const groupSystemPrompt = isGroup
? resolveIMessageGroupSystemPrompt({
groupConfig: groupListPolicy.groupConfig,
defaultConfig: groupListPolicy.defaultConfig,
})
: undefined;
return {
kind: "dispatch",
isGroup,
@@ -544,6 +585,7 @@ export function resolveIMessageInboundDecision(params: {
commandAuthorized,
effectiveDmAllowFrom,
effectiveGroupAllowFrom,
groupSystemPrompt,
};
}
@@ -665,6 +707,7 @@ export function buildIMessageInboundContext(params: {
ChatType: decision.isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined,
GroupSystemPrompt: decision.isGroup ? decision.groupSystemPrompt : undefined,
GroupMembers: decision.isGroup
? (params.message.participants ?? []).filter(Boolean).join(", ")
: undefined,

File diff suppressed because one or more lines are too long

View File

@@ -106,6 +106,14 @@ export type IMessageAccountConfig = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
/**
* Per-group system prompt. Injected into the agent's system prompt on
* every turn that handles a message in that group. Matches the shape
* already supported by Discord, Telegram, IRC, Slack, GoogleChat, and
* other group-capable channels. The wildcard `groups["*"]` entry is
* also honored.
*/
systemPrompt?: string;
}
>;
/** Heartbeat visibility settings for this channel. */

View File

@@ -1432,6 +1432,7 @@ export const IMessageAccountSchemaBase = z
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
systemPrompt: z.string().optional(),
})
.strict()
.optional(),