diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index c87fc47d0..bdc71f860 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -431,6 +431,71 @@ describe("getDmHistoryLimitFromSessionKey", () => { ).toBe(5); } }); + + it("returns per-DM override when set", () => { + const config = { + telegram: { + dmHistoryLimit: 15, + dms: { "123": { historyLimit: 5 } }, + }, + } as ClawdbotConfig; + expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(5); + }); + + it("falls back to provider default when per-DM not set", () => { + const config = { + telegram: { + dmHistoryLimit: 15, + dms: { "456": { historyLimit: 5 } }, + }, + } as ClawdbotConfig; + expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(15); + }); + + it("returns per-DM override for agent-prefixed keys", () => { + const config = { + telegram: { + dmHistoryLimit: 20, + dms: { "789": { historyLimit: 3 } }, + }, + } as ClawdbotConfig; + expect( + getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:789", config), + ).toBe(3); + }); + + it("handles userId with colons (e.g., email)", () => { + const config = { + msteams: { + dmHistoryLimit: 10, + dms: { "user@example.com": { historyLimit: 7 } }, + }, + } as ClawdbotConfig; + expect( + getDmHistoryLimitFromSessionKey("msteams:dm:user@example.com", config), + ).toBe(7); + }); + + it("returns undefined when per-DM historyLimit is not set", () => { + const config = { + telegram: { + dms: { "123": {} }, + }, + } as ClawdbotConfig; + expect( + getDmHistoryLimitFromSessionKey("telegram:dm:123", config), + ).toBeUndefined(); + }); + + it("returns 0 when per-DM historyLimit is explicitly 0 (unlimited)", () => { + const config = { + telegram: { + dmHistoryLimit: 15, + dms: { "123": { historyLimit: 0 } }, + }, + } as ClawdbotConfig; + expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(0); + }); }); describe("runEmbeddedPiAgent", () => { diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 862a6364f..a6b845aa4 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -446,12 +446,16 @@ export function limitHistoryTurns( } /** - * Extracts the provider name from a session key and looks up dmHistoryLimit - * from the provider config. + * Extracts the provider name and user ID from a session key and looks up + * dmHistoryLimit from the provider config, with per-DM override support. * * Session key formats: - * - `telegram:dm:123` → provider = telegram - * - `agent:main:telegram:dm:123` → provider = telegram (skip "agent::") + * - `telegram:dm:123` → provider = telegram, userId = 123 + * - `agent:main:telegram:dm:123` → provider = telegram, userId = 123 + * + * Resolution order: + * 1. Per-DM override: provider.dms[userId].historyLimit + * 2. Provider default: provider.dmHistoryLimit */ export function getDmHistoryLimitFromSessionKey( sessionKey: string | undefined, @@ -467,22 +471,49 @@ export function getDmHistoryLimitFromSessionKey( const provider = providerParts[0]?.toLowerCase(); if (!provider) return undefined; + // Extract userId: format is provider:dm:userId or provider:dm:userId:... + // The userId may contain colons (e.g., email addresses), so join remaining parts + const kind = providerParts[1]?.toLowerCase(); + const userId = providerParts.slice(2).join(":"); + + // Helper to get limit with per-DM override support + const getLimit = ( + providerConfig: + | { + dmHistoryLimit?: number; + dms?: Record; + } + | undefined, + ): number | undefined => { + if (!providerConfig) return undefined; + // Check per-DM override first + if ( + userId && + kind === "dm" && + providerConfig.dms?.[userId]?.historyLimit !== undefined + ) { + return providerConfig.dms[userId].historyLimit; + } + // Fall back to provider default + return providerConfig.dmHistoryLimit; + }; + // Map provider to config key switch (provider) { case "telegram": - return config.telegram?.dmHistoryLimit; + return getLimit(config.telegram); case "whatsapp": - return config.whatsapp?.dmHistoryLimit; + return getLimit(config.whatsapp); case "discord": - return config.discord?.dmHistoryLimit; + return getLimit(config.discord); case "slack": - return config.slack?.dmHistoryLimit; + return getLimit(config.slack); case "signal": - return config.signal?.dmHistoryLimit; + return getLimit(config.signal); case "imessage": - return config.imessage?.dmHistoryLimit; + return getLimit(config.imessage); case "msteams": - return config.msteams?.dmHistoryLimit; + return getLimit(config.msteams); default: return undefined; } diff --git a/src/config/types.ts b/src/config/types.ts index e748ec119..9bf8b6f06 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -149,6 +149,8 @@ export type WhatsAppConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Maximum media file size in MB. Default: 50. */ @@ -204,6 +206,8 @@ export type WhatsAppAccountConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; textChunkLimit?: number; mediaMaxMb?: number; blockStreaming?: boolean; @@ -385,6 +389,8 @@ export type TelegramAccountConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Disable block streaming for this account. */ @@ -530,6 +536,8 @@ export type DiscordAccountConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; /** Retry policy for outbound Discord API calls. */ retry?: OutboundRetryConfig; /** Per-action tool gating (default: true for all). */ @@ -628,6 +636,8 @@ export type SlackAccountConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; textChunkLimit?: number; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ @@ -689,6 +699,8 @@ export type SignalAccountConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; blockStreaming?: boolean; @@ -766,6 +778,8 @@ export type MSTeamsConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; /** Default reply style: "thread" replies to the message, "top-level" posts a new message. */ replyStyle?: MSTeamsReplyStyle; /** Per-team config. Key is team ID (from the /team/ URL path segment). */ @@ -804,6 +818,8 @@ export type IMessageAccountConfig = { historyLimit?: number; /** Max DM turns to keep as history context. */ dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; /** Include attachments + reactions in watch payloads. */ includeAttachments?: boolean; /** Max outbound media size in MB. */ @@ -941,6 +957,10 @@ export type GroupChatConfig = { historyLimit?: number; }; +export type DmConfig = { + historyLimit?: number; +}; + export type QueueConfig = { mode?: QueueMode; byProvider?: QueueModeByProvider; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 3d7e99da2..c30dba4bd 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -62,6 +62,10 @@ const GroupChatSchema = z }) .optional(); +const DmConfigSchema = z.object({ + historyLimit: z.number().int().min(0).optional(), +}); + const IdentitySchema = z .object({ name: z.string().optional(), @@ -274,6 +278,7 @@ const TelegramAccountSchemaBase = z.object({ groupPolicy: GroupPolicySchema.optional().default("open"), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), draftChunk: BlockStreamingChunkSchema.optional(), @@ -364,6 +369,7 @@ const DiscordAccountSchema = z.object({ groupPolicy: GroupPolicySchema.optional().default("open"), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), @@ -437,6 +443,7 @@ const SlackAccountSchema = z.object({ groupPolicy: GroupPolicySchema.optional().default("open"), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), @@ -492,6 +499,7 @@ const SignalAccountSchemaBase = z.object({ groupPolicy: GroupPolicySchema.optional().default("open"), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), @@ -541,6 +549,7 @@ const IMessageAccountSchemaBase = z.object({ groupPolicy: GroupPolicySchema.optional().default("open"), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), includeAttachments: z.boolean().optional(), mediaMaxMb: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(), @@ -616,6 +625,7 @@ const MSTeamsConfigSchema = z requireMention: z.boolean().optional(), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), replyStyle: MSTeamsReplyStyleSchema.optional(), teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(), }) @@ -1361,6 +1371,7 @@ export const ClawdbotSchema = z groupPolicy: GroupPolicySchema.optional().default("open"), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), @@ -1411,6 +1422,7 @@ export const ClawdbotSchema = z groupPolicy: GroupPolicySchema.optional().default("open"), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().int().positive().optional().default(50), blockStreaming: z.boolean().optional(),