mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 16:50:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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