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

channels.imessage.groups.<chat_id|*>.systemPrompt is now resolved at
inbound time and forwarded as ctxPayload.GroupSystemPrompt for group
messages, matching the established pattern in Discord, Telegram, IRC,
Slack, GoogleChat, and the retired BlueBubbles channel.

- Add 'systemPrompt?: string' to the channels.imessage.groups[*] entry
  schema (types.imessage.ts + zod parser).
- Capture groupListPolicy.groupConfig.systemPrompt at decision build
  time, falling back to the groups['*'] wildcard when the per-chat_id
  entry has no explicit prompt. DM decisions never carry the prompt.
- Wire decision.groupSystemPrompt through to ctxPayload.GroupSystemPrompt
  in buildIMessageInboundContext, gated on decision.isGroup.

Closes #78285.
This commit is contained in:
Omar Shahine
2026-05-08 12:10:02 +00:00
parent 89a435b1e8
commit b188d292bc
6 changed files with 259 additions and 3 deletions

View File

@@ -1,4 +1,4 @@
98f80c92fc4fcb37d41470216ae6cd19b094d7f67b0ddc4983eba04aba314fe0 config-baseline.json
d9c4b2035178d3ffe637b751036f12082d4f26761681bb8496b86550565307e8 config-baseline.core.json
ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json
f68749532fd8da60ac362187d783d92d43747703fa0dcc097eaef1e906eeda75 config-baseline.json
67c7db6eeb7f74dd454118e17304c5486ab59d33e7899c501b003c326d35db0f config-baseline.core.json
eb893b6b096635434b60c22bc1b466d598daf4c86c0c296da5a8b72df44f6021 config-baseline.channel.json
7a9ed89a6ff7e578bfcab7828ab660af59e62402a85bfbfc05d5ae3d975e9728 config-baseline.plugin.json

View File

@@ -271,6 +271,30 @@ 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. The wildcard `groups["*"]` entry is honored as a fallback when the per-`chat_id` entry has no `systemPrompt`.
```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.",
},
},
},
},
}
```
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,203 @@
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 systemPrompt as undefined", () => {
const decision = resolveIMessageInboundDecision(
buildDecisionParams({
cfg: buildCfgWithGroups({
"7": { systemPrompt: " " },
}),
}),
);
expect(decision.kind).toBe("dispatch");
if (decision.kind !== "dispatch") return;
expect(decision.groupSystemPrompt).toBeUndefined();
});
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",
sessionKey: "k",
mainSessionKey: "mk",
},
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

@@ -28,6 +28,7 @@ import {
resolveDmGroupAccessWithLists,
evaluateSupplementalContextVisibility,
} from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
import { resolveIMessageConversationRoute } from "../conversation-route.js";
@@ -150,6 +151,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 +531,19 @@ export function resolveIMessageInboundDecision(params: {
return { kind: "drop", reason: "no mention" };
}
// Per-chat_id `systemPrompt` wins; fall back to the `groups["*"]` wildcard
// entry when the matched group has no explicit prompt. Mirrors the per-group
// systemPrompt pattern already supported by Telegram, IRC, Discord, Slack,
// GoogleChat, and the retired BlueBubbles channel.
const groupSystemPrompt = isGroup
? (normalizeOptionalString(
(groupListPolicy.groupConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt,
) ??
normalizeOptionalString(
(groupListPolicy.defaultConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt,
))
: undefined;
return {
kind: "dispatch",
isGroup,
@@ -544,6 +562,7 @@ export function resolveIMessageInboundDecision(params: {
commandAuthorized,
effectiveDmAllowFrom,
effectiveGroupAllowFrom,
groupSystemPrompt,
};
}
@@ -665,6 +684,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,

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(),