mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 08:30:46 +00:00
fix(imessage): match WhatsApp's per-group systemPrompt resolution semantic
Earlier draft used a simple normalize-and-fall-through resolver: any empty/whitespace per-chat_id systemPrompt fell through to the groups['*'] wildcard. That doesn't match the WhatsApp resolver (extensions/whatsapp/src/system-prompt.ts), where defining the systemPrompt key on the specific group (even as '') means 'this group has no prompt' and suppresses the wildcard. Make iMessage resolution byte-identical: - specific != null && specific.systemPrompt != null -> use the trimmed specific (empty trim -> undefined, wildcard suppressed). - otherwise -> trimmed wildcard. Add the resolver as a small exported helper resolveIMessageGroupSystemPrompt so the unit tests cover it directly. Update the per-group systemPrompt doc section in docs/channels/imessage.md to copy WhatsApp's resolution hierarchy language and add an explicit-suppression example. Refs #78285.
This commit is contained in:
@@ -273,7 +273,10 @@ If SIP-disabled isn't acceptable for your threat model:
|
||||
|
||||
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`.
|
||||
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
|
||||
{
|
||||
@@ -287,6 +290,10 @@ If SIP-disabled isn't acceptable for your threat model:
|
||||
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: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -92,10 +92,14 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
expect(decision.groupSystemPrompt).toBe("Specific group voice.");
|
||||
});
|
||||
|
||||
it("treats whitespace-only systemPrompt as undefined", () => {
|
||||
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: " " },
|
||||
}),
|
||||
}),
|
||||
@@ -105,6 +109,34 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
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
|
||||
|
||||
@@ -28,7 +28,6 @@ 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";
|
||||
@@ -132,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;
|
||||
@@ -532,16 +556,15 @@ export function resolveIMessageInboundDecision(params: {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
? (normalizeOptionalString(
|
||||
(groupListPolicy.groupConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt,
|
||||
) ??
|
||||
normalizeOptionalString(
|
||||
(groupListPolicy.defaultConfig as { systemPrompt?: unknown } | undefined)?.systemPrompt,
|
||||
))
|
||||
? resolveIMessageGroupSystemPrompt({
|
||||
groupConfig: groupListPolicy.groupConfig,
|
||||
defaultConfig: groupListPolicy.defaultConfig,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user