mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -1432,6 +1432,7 @@ export const IMessageAccountSchemaBase = z
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
Reference in New Issue
Block a user