diff --git a/CHANGELOG.md b/CHANGELOG.md index e19554671..11b7c06bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ - Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan. - Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight. - Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm. +- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff. +- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff. - Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults. - iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski. - iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks). diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 755071561..6928ac917 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -100,6 +100,7 @@ Telegram is the only provider with draft streaming: - `partial`: draft updates with the latest stream text. - `block`: draft updates in chunked blocks (same chunker rules). - `off`: no draft streaming. +- Draft chunk config (only for `streamMode: "block"`): `telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`). - Draft streaming is separate from block streaming; block replies are off by default and only enabled by `*.blockStreaming: true` on non-Telegram providers. - Final reply is still a normal message. - `/reasoning stream` writes reasoning into the draft bubble (Telegram only). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 80efd2c70..975c52c49 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -676,6 +676,11 @@ Multi-account support lives under `telegram.accounts` (see the multi-account sec }, replyToMode: "first", // off | first | all streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming) + draftChunk: { // optional; only for streamMode=block + minChars: 200, + maxChars: 800, + breakPreference: "paragraph" // paragraph | newline | sentence + }, actions: { reactions: true, sendMessage: true }, // tool action gates (false disables) mediaMaxMb: 5, retry: { // outbound retry policy diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index a669e4e56..0d1e0660e 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -209,6 +209,9 @@ Config: - `partial`: update the draft bubble with the latest streaming text. - `block`: update the draft bubble in larger blocks (chunked). - `off`: disable draft streaming. +- Optional (only for `streamMode: "block"`): + - `telegram.draftChunk: { minChars?, maxChars?, breakPreference? }` + - defaults: `minChars: 200`, `maxChars: 800`, `breakPreference: "paragraph"` (clamped to `telegram.textChunkLimit`). Note: draft streaming is separate from **block streaming** (provider messages). Block streaming is off by default and requires `telegram.blockStreaming: true` diff --git a/src/auto-reply/reply/block-streaming.test.ts b/src/auto-reply/reply/block-streaming.test.ts new file mode 100644 index 000000000..17b6c505e --- /dev/null +++ b/src/auto-reply/reply/block-streaming.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveTelegramDraftStreamingChunking } from "./block-streaming.js"; + +describe("resolveTelegramDraftStreamingChunking", () => { + it("uses smaller defaults than block streaming", () => { + const chunking = resolveTelegramDraftStreamingChunking( + undefined, + "default", + ); + expect(chunking).toEqual({ + minChars: 200, + maxChars: 800, + breakPreference: "paragraph", + }); + }); + + it("clamps to telegram.textChunkLimit", () => { + const cfg: ClawdbotConfig = { + telegram: { allowFrom: ["*"], textChunkLimit: 150 }, + }; + const chunking = resolveTelegramDraftStreamingChunking(cfg, "default"); + expect(chunking).toEqual({ + minChars: 150, + maxChars: 150, + breakPreference: "paragraph", + }); + }); + + it("supports per-account overrides", () => { + const cfg: ClawdbotConfig = { + telegram: { + allowFrom: ["*"], + accounts: { + default: { + allowFrom: ["*"], + draftChunk: { + minChars: 10, + maxChars: 20, + breakPreference: "sentence", + }, + }, + }, + }, + }; + const chunking = resolveTelegramDraftStreamingChunking(cfg, "default"); + expect(chunking).toEqual({ + minChars: 10, + maxChars: 20, + breakPreference: "sentence", + }); + }); +}); diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index da60e1ce9..3a0e1801e 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -5,6 +5,8 @@ import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000; +const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; +const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800; const PROVIDER_COALESCE_DEFAULTS: Partial< Record > = { @@ -72,6 +74,39 @@ export function resolveBlockStreamingChunking( return { minChars, maxChars, breakPreference }; } +export function resolveTelegramDraftStreamingChunking( + cfg: ClawdbotConfig | undefined, + accountId?: string | null, +): { + minChars: number; + maxChars: number; + breakPreference: "paragraph" | "newline" | "sentence"; +} { + const providerKey: TextChunkProvider = "telegram"; + const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId); + const normalizedAccountId = normalizeAccountId(accountId); + const draftCfg = + cfg?.telegram?.accounts?.[normalizedAccountId]?.draftChunk ?? + cfg?.telegram?.draftChunk; + + const maxRequested = Math.max( + 1, + Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX), + ); + const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); + const minRequested = Math.max( + 1, + Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN), + ); + const minChars = Math.min(minRequested, maxChars); + const breakPreference = + draftCfg?.breakPreference === "newline" || + draftCfg?.breakPreference === "sentence" + ? draftCfg.breakPreference + : "paragraph"; + return { minChars, maxChars, breakPreference }; +} + export function resolveBlockStreamingCoalescing( cfg: ClawdbotConfig | undefined, provider?: string, diff --git a/src/config/schema.ts b/src/config/schema.ts index 5778968e8..8cba062dd 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -120,6 +120,10 @@ const FIELD_LABELS: Record = { "telegram.botToken": "Telegram Bot Token", "telegram.dmPolicy": "Telegram DM Policy", "telegram.streamMode": "Telegram Draft Stream Mode", + "telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", + "telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", + "telegram.draftChunk.breakPreference": + "Telegram Draft Chunk Break Preference", "telegram.retry.attempts": "Telegram Retry Attempts", "telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", @@ -203,6 +207,12 @@ const FIELD_HELP: Record = { 'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].', "telegram.streamMode": "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", + "telegram.draftChunk.minChars": + 'Minimum chars before emitting a Telegram draft update when telegram.streamMode="block" (default: 200).', + "telegram.draftChunk.maxChars": + 'Target max size for a Telegram draft update chunk when telegram.streamMode="block" (default: 800; clamped to telegram.textChunkLimit).', + "telegram.draftChunk.breakPreference": + "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", "telegram.retry.attempts": "Max retry attempts for outbound Telegram API calls (default: 3).", "telegram.retry.minDelayMs": diff --git a/src/config/types.ts b/src/config/types.ts index 842dade70..36c2b3f4d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -22,6 +22,12 @@ export type BlockStreamingCoalesceConfig = { idleMs?: number; }; +export type BlockStreamingChunkConfig = { + minChars?: number; + maxChars?: number; + breakPreference?: "paragraph" | "newline" | "sentence"; +}; + export type HumanDelayConfig = { /** Delay style for block replies (off|natural|custom). */ mode?: "off" | "natural" | "custom"; @@ -345,6 +351,8 @@ export type TelegramAccountConfig = { textChunkLimit?: number; /** Disable block streaming for this account. */ blockStreaming?: boolean; + /** Chunking config for draft streaming in `streamMode: "block"`. */ + draftChunk?: BlockStreamingChunkConfig; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; /** Draft streaming mode for Telegram (off|partial|block). Default: partial. */ @@ -1318,11 +1326,7 @@ export type AgentDefaultsConfig = { */ blockStreamingBreak?: "text_end" | "message_end"; /** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */ - blockStreamingChunk?: { - minChars?: number; - maxChars?: number; - breakPreference?: "paragraph" | "newline" | "sentence"; - }; + blockStreamingChunk?: BlockStreamingChunkConfig; /** * Block reply coalescing (merge streamed chunks before send). * idleMs: wait time before flushing when idle. diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c4314efc1..c63db5593 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -103,6 +103,18 @@ const BlockStreamingCoalesceSchema = z.object({ idleMs: z.number().int().nonnegative().optional(), }); +const BlockStreamingChunkSchema = z.object({ + minChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + breakPreference: z + .union([ + z.literal("paragraph"), + z.literal("newline"), + z.literal("sentence"), + ]) + .optional(), +}); + const HumanDelaySchema = z.object({ mode: z .union([z.literal("off"), z.literal("natural"), z.literal("custom")]) @@ -207,6 +219,7 @@ const TelegramAccountSchemaBase = z.object({ groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), + draftChunk: BlockStreamingChunkSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), mediaMaxMb: z.number().positive().optional(), @@ -1038,19 +1051,7 @@ const AgentDefaultsSchema = z blockStreamingBreak: z .union([z.literal("text_end"), z.literal("message_end")]) .optional(), - blockStreamingChunk: z - .object({ - minChars: z.number().int().positive().optional(), - maxChars: z.number().int().positive().optional(), - breakPreference: z - .union([ - z.literal("paragraph"), - z.literal("newline"), - z.literal("sentence"), - ]) - .optional(), - }) - .optional(), + blockStreamingChunk: BlockStreamingChunkSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), humanDelay: HumanDelaySchema.optional(), timeoutSeconds: z.number().int().positive().optional(), diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 0d8ddda8b..306a3e87d 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -19,7 +19,7 @@ import { listNativeCommandSpecs, } from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; -import { resolveBlockStreamingChunking } from "../auto-reply/reply/block-streaming.js"; +import { resolveTelegramDraftStreamingChunking } from "../auto-reply/reply/block-streaming.js"; import { buildMentionRegexes, matchesMentionPatterns, @@ -749,7 +749,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { : undefined; const draftChunking = draftStream && streamMode === "block" - ? resolveBlockStreamingChunking(cfg, "telegram", route.accountId) + ? resolveTelegramDraftStreamingChunking(cfg, route.accountId) : undefined; const draftChunker = draftChunking ? new EmbeddedBlockChunker(draftChunking)