Add per-DM and per-group Agent Preset overrides (4.9.1-ops.19).

Access grants can pin an agentPreset on direct members and groups; new sessions resolve override over the bot-global preset.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 18:53:43 +08:00
parent 7993499273
commit 2718586fda
12 changed files with 590 additions and 232 deletions

View file

@ -7,12 +7,19 @@ export const ACCESS_GRANT_VERSION = 1;
export const ACCESS_PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export const ACCESS_GRANT_PHONE_MAX_LENGTH = 32;
export const ACCESS_GRANT_GROUP_JID_PATTERN = /^\d{5,32}@g\.us$/;
const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
/** @typedef {'direct' | 'group'} AccessGrantScene */
/** @typedef {'approve' | 'deny'} AccessPendingAction */
function normalizeOptionalAgentPresetId(value) {
if (typeof value !== 'string') return null;
const id = value.trim();
return AGENT_PRESET_ID.test(id) ? id : null;
}
function invalidGrant(message) {
const error = new TypeError(message);
error.code = 'access-grant-invalid';
@ -75,7 +82,7 @@ export function validateGroupJid(value) {
/**
* @param {unknown} input
* @returns {{ phone: string, canExecuteCommands: boolean }}
* @returns {{ phone: string, canExecuteCommands: boolean, agentPreset?: string }}
*/
function validateMember(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
@ -85,7 +92,24 @@ function validateMember(input) {
const canExecuteCommands = input.canExecuteCommands === undefined
? true
: Boolean(input.canExecuteCommands);
return Object.freeze({ phone, canExecuteCommands });
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
phone,
canExecuteCommands,
...(agentPreset ? { agentPreset } : {}),
});
}
/**
* Optional Agent Preset override. Empty/null means follow the bot-level preset.
* @param {unknown} value
* @returns {string|undefined}
*/
function optionalAgentPreset(value) {
if (value == null || value === '') return undefined;
const id = normalizeOptionalAgentPresetId(value);
if (!id) invalidGrant('Agent Preset 无效。');
return id;
}
/**
@ -119,8 +143,10 @@ function validateGroupGrant(input) {
invalidGrant('群成员不能包含重复电话。');
}
const title = typeof input.title === 'string' ? input.title.trim().slice(0, 128) : '';
const agentPreset = optionalAgentPreset(input.agentPreset);
return Object.freeze({
...(title ? { title } : {}),
...(agentPreset ? { agentPreset } : {}),
admins,
members,
});
@ -282,6 +308,30 @@ export function normalizeAccessGrant(input) {
}
}
/**
* Resolve a chat-scoped Agent Preset override from the access grant.
* Returns null when the chat should follow the bot-level (global) preset.
* Priority: direct-member or group override > bot global (caller applies global).
* @param {unknown} grant
* @param {{ kind?: string, phone?: string, senderId?: string, groupJid?: string, conversationId?: string }} [context]
* @returns {string|null}
*/
export function resolveAccessAgentPreset(grant, context = {}) {
const doc = normalizeAccessGrant(grant);
if (!doc) return null;
if (context.kind === 'group') {
const groupJid = typeof context.groupJid === 'string' && context.groupJid.trim()
? context.groupJid.trim()
: typeof context.conversationId === 'string' ? context.conversationId.trim() : '';
if (!groupJid || !ACCESS_GRANT_GROUP_JID_PATTERN.test(groupJid)) return null;
return normalizeOptionalAgentPresetId(doc.groups[groupJid]?.agentPreset) ?? null;
}
const phone = normalizeAccessPhone(context.phone ?? context.senderId);
if (!phone) return null;
const member = doc.directMembers.find((entry) => entry.phone === phone);
return normalizeOptionalAgentPresetId(member?.agentPreset) ?? null;
}
/**
* Seed owner phone as the first global admin when missing.
* @param {object|null} grant

View file

@ -1288,9 +1288,12 @@ export function createBotWorkspaceScope(
throw error;
}
const generation = workspaces.generationFor(botId);
const agentPreset = workspaces.agentPresetFor(botId);
const { agentPreset: requestedPreset, ...createOptions } = options ?? {};
const agentPreset = requestedPreset !== undefined
? requestedPreset
: workspaces.agentPresetFor(botId);
const sessionId = await target.createSession({
...options,
...createOptions,
workspace: workspaces.workspaceFor(botId),
...(agentPreset == null ? {} : { agentPreset }),
});

View file

@ -148,6 +148,8 @@ export class TextHarnessBridge {
#logger;
#replyTimeoutMs;
#signal;
/** @type {((message: object) => string|null|undefined)|null} */
#resolveAgentPreset;
#queues = new Map();
#pendingInteractions = new Map();
#interactionKeys = new Map();
@ -170,6 +172,7 @@ export class TextHarnessBridge {
logger = console,
replyTimeoutMs = 600_000,
signal,
resolveAgentPreset,
}) {
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
@ -185,6 +188,7 @@ export class TextHarnessBridge {
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#signal = signal;
this.#resolveAgentPreset = typeof resolveAgentPreset === 'function' ? resolveAgentPreset : null;
this.#approvals = new HarnessApprovalQueue({
label: descriptor.key,
logger,
@ -735,7 +739,13 @@ export class TextHarnessBridge {
text,
content,
contextEnhanced,
createOptions: this.#signal ? { signal: this.#signal } : undefined,
createOptions: (() => {
const agentPreset = this.#resolveAgentPreset?.(message);
const options = {};
if (this.#signal) options.signal = this.#signal;
if (typeof agentPreset === 'string' && agentPreset) options.agentPreset = agentPreset;
return Object.keys(options).length > 0 ? options : undefined;
})(),
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
askOptions: {
timeoutMs: this.#replyTimeoutMs,

View file

@ -7,7 +7,7 @@ import {
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { emptyAccessGrant, ensureGroupBucket } from '../shared/access-grant.mjs';
import { emptyAccessGrant, ensureGroupBucket, normalizeAccessPhone, phoneFromWhatsappJid, resolveAccessAgentPreset } from '../shared/access-grant.mjs';
import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { t } from '../shared/i18n.mjs';
import { ImagePromptError } from '../shared/image-prompt.mjs';
@ -823,6 +823,20 @@ export class WhatsappRuntime {
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
signal: controller.signal,
resolveAgentPreset: (message) => {
if (!this.#workspaces || !this.#botId) return null;
const grant = this.#workspaces.accessGrantFor(this.#botId);
return resolveAccessAgentPreset(grant, {
kind: message?.kind === 'group' ? 'group' : 'direct',
groupJid: message?.conversationId,
conversationId: message?.conversationId,
phone: phoneFromWhatsappJid(message?.senderId)
?? normalizeAccessPhone(message?.senderId)
?? phoneFromWhatsappJid(message?.senderAlternateId)
?? normalizeAccessPhone(message?.senderAlternateId),
senderId: message?.senderId,
});
},
});
const now = Date.now();
this.#status.ready = true;