fix(auto-reply): coalesce block replies and document streaming toggles (#536) (thanks @mcinteerj)
This commit is contained in:
@@ -87,7 +87,7 @@
|
|||||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
|
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
|
||||||
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
|
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
|
||||||
- Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
|
- Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
|
||||||
|
- Auto-reply: add per-provider block streaming toggles and coalesce streamed blocks to reduce line spam. (#536) — thanks @mcinteerj
|
||||||
- Auto-reply: fix /status usage summary filtering for the active provider.
|
- Auto-reply: fix /status usage summary filtering for the active provider.
|
||||||
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
|
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
|
||||||
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
|
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ via `agents.defaults.blockStreamingDefault: "off"` if you only want the final re
|
|||||||
Tune the boundary via `agents.defaults.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end).
|
Tune the boundary via `agents.defaults.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end).
|
||||||
Control soft block chunking with `agents.defaults.blockStreamingChunk` (defaults to
|
Control soft block chunking with `agents.defaults.blockStreamingChunk` (defaults to
|
||||||
800–1200 chars; prefers paragraph breaks, then newlines; sentences last).
|
800–1200 chars; prefers paragraph breaks, then newlines; sentences last).
|
||||||
|
Coalesce streamed chunks with `agents.defaults.blockStreamingCoalesce` to reduce
|
||||||
|
single-line spam (idle-based merging before send).
|
||||||
Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
||||||
streams tool output via agent events when available.
|
streams tool output via agent events when available.
|
||||||
More details: [Streaming + chunking](/concepts/streaming).
|
More details: [Streaming + chunking](/concepts/streaming).
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ Legend:
|
|||||||
|
|
||||||
**Controls:**
|
**Controls:**
|
||||||
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
||||||
|
- Provider overrides: `*.blockStreaming` (and per-account variants) to force `"on"`/`"off"` per provider.
|
||||||
- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"`.
|
- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"`.
|
||||||
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||||
|
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
||||||
- Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`).
|
- Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`).
|
||||||
- Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
- Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
||||||
|
|
||||||
@@ -54,6 +56,20 @@ Block chunking is implemented by `EmbeddedBlockChunker`:
|
|||||||
|
|
||||||
`maxChars` is clamped to the provider `textChunkLimit`, so you can’t exceed per-provider caps.
|
`maxChars` is clamped to the provider `textChunkLimit`, so you can’t exceed per-provider caps.
|
||||||
|
|
||||||
|
## Coalescing (merge streamed blocks)
|
||||||
|
|
||||||
|
When block streaming is enabled, Clawdbot can **merge consecutive block chunks**
|
||||||
|
before sending them out. This reduces “single-line spam” while still providing
|
||||||
|
progressive output.
|
||||||
|
|
||||||
|
- Coalescing waits for **idle gaps** (`idleMs`) before flushing.
|
||||||
|
- Buffers are capped by `maxChars` and will flush if they exceed it.
|
||||||
|
- `minChars` prevents tiny fragments from sending until enough text accumulates
|
||||||
|
(final flush always sends remaining text).
|
||||||
|
- Joiner is derived from `blockStreamingChunk.breakPreference`
|
||||||
|
(`paragraph` → `\n\n`, `newline` → `\n`, `sentence` → space).
|
||||||
|
- Provider overrides are available via `*.blockStreamingCoalesce` (including per-account configs).
|
||||||
|
|
||||||
## “Stream chunks or everything”
|
## “Stream chunks or everything”
|
||||||
|
|
||||||
This maps to:
|
This maps to:
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
maxChars: 1200,
|
maxChars: 1200,
|
||||||
breakPreference: "paragraph"
|
breakPreference: "paragraph"
|
||||||
},
|
},
|
||||||
|
blockStreamingCoalesce: {
|
||||||
|
idleMs: 400
|
||||||
|
},
|
||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
typingIntervalSeconds: 5,
|
typingIntervalSeconds: 5,
|
||||||
|
|||||||
@@ -1142,6 +1142,7 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
|
|||||||
|
|
||||||
Block streaming:
|
Block streaming:
|
||||||
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
- `agents.defaults.blockStreamingDefault`: `"on"`/`"off"` (default on).
|
||||||
|
- Provider overrides: `*.blockStreaming` (and per-account variants) to force block streaming on/off.
|
||||||
- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
|
- `agents.defaults.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
|
||||||
- `agents.defaults.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to
|
- `agents.defaults.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to
|
||||||
800–1200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences.
|
800–1200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences.
|
||||||
@@ -1151,6 +1152,12 @@ Block streaming:
|
|||||||
agents: { defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } } }
|
agents: { defaults: { blockStreamingChunk: { minChars: 800, maxChars: 1200 } } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- `agents.defaults.blockStreamingCoalesce`: merge streamed blocks before sending.
|
||||||
|
Defaults to `{ idleMs: 400 }` and inherits `minChars` from `blockStreamingChunk`
|
||||||
|
with `maxChars` capped to the provider text limit.
|
||||||
|
Provider overrides: `whatsapp.blockStreamingCoalesce`, `telegram.blockStreamingCoalesce`,
|
||||||
|
`discord.blockStreamingCoalesce`, `slack.blockStreamingCoalesce`, `signal.blockStreamingCoalesce`,
|
||||||
|
`imessage.blockStreamingCoalesce`, `msteams.blockStreamingCoalesce` (and per-account variants).
|
||||||
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
|
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
|
||||||
|
|
||||||
Typing indicators:
|
Typing indicators:
|
||||||
|
|||||||
@@ -1045,6 +1045,15 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
if (params.onBlockReply) {
|
||||||
|
if (blockChunker?.hasBuffered()) {
|
||||||
|
blockChunker.drain({ force: true, emit: emitBlockChunk });
|
||||||
|
blockChunker.reset();
|
||||||
|
} else if (blockBuffer.length > 0) {
|
||||||
|
emitBlockChunk(blockBuffer);
|
||||||
|
blockBuffer = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
if (pendingCompactionRetry > 0) {
|
if (pendingCompactionRetry > 0) {
|
||||||
resolveCompactionRetry();
|
resolveCompactionRetry();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ describe("block streaming", () => {
|
|||||||
|
|
||||||
const res = await replyPromise;
|
const res = await replyPromise;
|
||||||
expect(res).toBeUndefined();
|
expect(res).toBeUndefined();
|
||||||
expect(seen).toEqual(["first", "second"]);
|
expect(seen).toEqual(["first\n\nsecond"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -454,7 +454,11 @@ export async function getReplyFromConfig(
|
|||||||
const blockStreamingEnabled =
|
const blockStreamingEnabled =
|
||||||
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
|
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
|
||||||
const blockReplyChunking = blockStreamingEnabled
|
const blockReplyChunking = blockStreamingEnabled
|
||||||
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider)
|
? resolveBlockStreamingChunking(
|
||||||
|
cfg,
|
||||||
|
sessionCtx.Provider,
|
||||||
|
sessionCtx.AccountId,
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const modelState = await createModelSelectionState({
|
const modelState = await createModelSelectionState({
|
||||||
|
|||||||
132
src/auto-reply/reply/agent-runner.block-streaming.test.ts
Normal file
132
src/auto-reply/reply/agent-runner.block-streaming.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { TemplateContext } from "../templating.js";
|
||||||
|
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||||
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
|
const runEmbeddedPiAgentMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-fallback.js", () => ({
|
||||||
|
runWithModelFallback: async ({
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
run,
|
||||||
|
}: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
run: (provider: string, model: string) => Promise<unknown>;
|
||||||
|
}) => ({
|
||||||
|
result: await run(provider, model),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||||
|
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||||
|
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./queue.js", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
enqueueFollowupRun: vi.fn(),
|
||||||
|
scheduleFollowupDrain: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { runReplyAgent } from "./agent-runner.js";
|
||||||
|
|
||||||
|
describe("runReplyAgent block streaming", () => {
|
||||||
|
it("coalesces duplicate text_end block replies", async () => {
|
||||||
|
const onBlockReply = vi.fn();
|
||||||
|
runEmbeddedPiAgentMock.mockImplementationOnce(async (params) => {
|
||||||
|
const block = params.onBlockReply as
|
||||||
|
| ((payload: { text?: string }) => void)
|
||||||
|
| undefined;
|
||||||
|
block?.({ text: "Hello" });
|
||||||
|
block?.({ text: "Hello" });
|
||||||
|
return {
|
||||||
|
payloads: [{ text: "Final message" }],
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "discord",
|
||||||
|
OriginatingTo: "channel:C1",
|
||||||
|
AccountId: "primary",
|
||||||
|
MessageSid: "msg",
|
||||||
|
} as unknown as TemplateContext;
|
||||||
|
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||||
|
const followupRun = {
|
||||||
|
prompt: "hello",
|
||||||
|
summaryLine: "hello",
|
||||||
|
enqueuedAt: Date.now(),
|
||||||
|
run: {
|
||||||
|
sessionId: "session",
|
||||||
|
sessionKey: "main",
|
||||||
|
messageProvider: "discord",
|
||||||
|
sessionFile: "/tmp/session.jsonl",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
config: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
blockStreamingCoalesce: {
|
||||||
|
minChars: 1,
|
||||||
|
maxChars: 200,
|
||||||
|
idleMs: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skillsSnapshot: {},
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude",
|
||||||
|
thinkLevel: "low",
|
||||||
|
verboseLevel: "off",
|
||||||
|
elevatedLevel: "off",
|
||||||
|
bashElevated: {
|
||||||
|
enabled: false,
|
||||||
|
allowed: false,
|
||||||
|
defaultLevel: "off",
|
||||||
|
},
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
blockReplyBreak: "text_end",
|
||||||
|
},
|
||||||
|
} as unknown as FollowupRun;
|
||||||
|
|
||||||
|
const result = await runReplyAgent({
|
||||||
|
commandBody: "hello",
|
||||||
|
followupRun,
|
||||||
|
queueKey: "main",
|
||||||
|
resolvedQueue,
|
||||||
|
shouldSteer: false,
|
||||||
|
shouldFollowup: false,
|
||||||
|
isActive: false,
|
||||||
|
isStreaming: false,
|
||||||
|
opts: { onBlockReply },
|
||||||
|
typing,
|
||||||
|
sessionCtx,
|
||||||
|
defaultModel: "anthropic/claude-opus-4-5",
|
||||||
|
resolvedVerboseLevel: "off",
|
||||||
|
isNewSession: false,
|
||||||
|
blockStreamingEnabled: true,
|
||||||
|
blockReplyChunking: {
|
||||||
|
minChars: 1,
|
||||||
|
maxChars: 200,
|
||||||
|
breakPreference: "paragraph",
|
||||||
|
},
|
||||||
|
resolvedBlockStreamingBreak: "text_end",
|
||||||
|
shouldInjectGroupIntro: false,
|
||||||
|
typingMode: "instant",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onBlockReply.mock.calls[0][0].text).toBe("Hello");
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,8 @@ import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
|||||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import { extractAudioTag } from "./audio-tags.js";
|
import { extractAudioTag } from "./audio-tags.js";
|
||||||
|
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||||
|
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
|
||||||
import { createFollowupRunner } from "./followup-runner.js";
|
import { createFollowupRunner } from "./followup-runner.js";
|
||||||
import {
|
import {
|
||||||
enqueueFollowupRun,
|
enqueueFollowupRun,
|
||||||
@@ -132,23 +134,6 @@ const appendUsageLine = (
|
|||||||
return updated;
|
return updated;
|
||||||
};
|
};
|
||||||
|
|
||||||
const withTimeout = async <T>(
|
|
||||||
promise: Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
timeoutError: Error,
|
|
||||||
): Promise<T> => {
|
|
||||||
if (!timeoutMs || timeoutMs <= 0) return promise;
|
|
||||||
let timer: NodeJS.Timeout | undefined;
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
timer = setTimeout(() => reject(timeoutError), timeoutMs);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await Promise.race([promise, timeoutPromise]);
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function runReplyAgent(params: {
|
export async function runReplyAgent(params: {
|
||||||
commandBody: string;
|
commandBody: string;
|
||||||
followupRun: FollowupRun;
|
followupRun: FollowupRun;
|
||||||
@@ -228,29 +213,9 @@ export async function runReplyAgent(params: {
|
|||||||
return resolvedVerboseLevel === "on";
|
return resolvedVerboseLevel === "on";
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamedPayloadKeys = new Set<string>();
|
|
||||||
const pendingStreamedPayloadKeys = new Set<string>();
|
|
||||||
const pendingBlockTasks = new Set<Promise<void>>();
|
|
||||||
const pendingToolTasks = new Set<Promise<void>>();
|
const pendingToolTasks = new Set<Promise<void>>();
|
||||||
let blockReplyChain: Promise<void> = Promise.resolve();
|
|
||||||
let blockReplyAborted = false;
|
|
||||||
let didLogBlockReplyAbort = false;
|
|
||||||
let didStreamBlockReply = false;
|
|
||||||
const blockReplyTimeoutMs =
|
const blockReplyTimeoutMs =
|
||||||
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS;
|
||||||
const buildPayloadKey = (payload: ReplyPayload) => {
|
|
||||||
const text = payload.text?.trim() ?? "";
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
|
||||||
? payload.mediaUrls
|
|
||||||
: payload.mediaUrl
|
|
||||||
? [payload.mediaUrl]
|
|
||||||
: [];
|
|
||||||
return JSON.stringify({
|
|
||||||
text,
|
|
||||||
mediaList,
|
|
||||||
replyToId: payload.replyToId ?? null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const replyToChannel =
|
const replyToChannel =
|
||||||
sessionCtx.OriginatingChannel ??
|
sessionCtx.OriginatingChannel ??
|
||||||
((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as
|
((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as
|
||||||
@@ -265,6 +230,23 @@ export async function runReplyAgent(params: {
|
|||||||
replyToChannel,
|
replyToChannel,
|
||||||
);
|
);
|
||||||
const cfg = followupRun.run.config;
|
const cfg = followupRun.run.config;
|
||||||
|
const blockReplyCoalescing =
|
||||||
|
blockStreamingEnabled && opts?.onBlockReply
|
||||||
|
? resolveBlockStreamingCoalescing(
|
||||||
|
cfg,
|
||||||
|
sessionCtx.Provider,
|
||||||
|
sessionCtx.AccountId,
|
||||||
|
blockReplyChunking,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const blockReplyPipeline =
|
||||||
|
blockStreamingEnabled && opts?.onBlockReply
|
||||||
|
? createBlockReplyPipeline({
|
||||||
|
onBlockReply: opts.onBlockReply,
|
||||||
|
timeoutMs: blockReplyTimeoutMs,
|
||||||
|
coalescing: blockReplyCoalescing,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
if (shouldSteer && isStreaming) {
|
if (shouldSteer && isStreaming) {
|
||||||
const steered = queueEmbeddedPiMessage(
|
const steered = queueEmbeddedPiMessage(
|
||||||
@@ -511,15 +493,6 @@ export async function runReplyAgent(params: {
|
|||||||
text: cleaned,
|
text: cleaned,
|
||||||
audioAsVoice: audioTagResult.audioAsVoice,
|
audioAsVoice: audioTagResult.audioAsVoice,
|
||||||
});
|
});
|
||||||
const payloadKey = buildPayloadKey(blockPayload);
|
|
||||||
if (
|
|
||||||
streamedPayloadKeys.has(payloadKey) ||
|
|
||||||
pendingStreamedPayloadKeys.has(payloadKey)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (blockReplyAborted) return;
|
|
||||||
pendingStreamedPayloadKeys.add(payloadKey);
|
|
||||||
void typingSignals
|
void typingSignals
|
||||||
.signalTextDelta(taggedPayload.text)
|
.signalTextDelta(taggedPayload.text)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -527,50 +500,7 @@ export async function runReplyAgent(params: {
|
|||||||
`block reply typing signal failed: ${String(err)}`,
|
`block reply typing signal failed: ${String(err)}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const timeoutError = new Error(
|
blockReplyPipeline?.enqueue(blockPayload);
|
||||||
`block reply delivery timed out after ${blockReplyTimeoutMs}ms`,
|
|
||||||
);
|
|
||||||
const abortController = new AbortController();
|
|
||||||
blockReplyChain = blockReplyChain
|
|
||||||
.then(async () => {
|
|
||||||
if (blockReplyAborted) return false;
|
|
||||||
await withTimeout(
|
|
||||||
opts.onBlockReply?.(blockPayload, {
|
|
||||||
abortSignal: abortController.signal,
|
|
||||||
timeoutMs: blockReplyTimeoutMs,
|
|
||||||
}) ?? Promise.resolve(),
|
|
||||||
blockReplyTimeoutMs,
|
|
||||||
timeoutError,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.then((didSend) => {
|
|
||||||
if (!didSend) return;
|
|
||||||
streamedPayloadKeys.add(payloadKey);
|
|
||||||
didStreamBlockReply = true;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err === timeoutError) {
|
|
||||||
abortController.abort();
|
|
||||||
blockReplyAborted = true;
|
|
||||||
if (!didLogBlockReplyAbort) {
|
|
||||||
didLogBlockReplyAbort = true;
|
|
||||||
logVerbose(
|
|
||||||
`block reply delivery timed out after ${blockReplyTimeoutMs}ms; skipping remaining block replies to preserve ordering`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logVerbose(
|
|
||||||
`block reply delivery failed: ${String(err)}`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
pendingStreamedPayloadKeys.delete(payloadKey);
|
|
||||||
});
|
|
||||||
const task = blockReplyChain;
|
|
||||||
pendingBlockTasks.add(task);
|
|
||||||
void task.finally(() => pendingBlockTasks.delete(task));
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
shouldEmitToolResult,
|
shouldEmitToolResult,
|
||||||
@@ -684,8 +614,9 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payloadArray = runResult.payloads ?? [];
|
const payloadArray = runResult.payloads ?? [];
|
||||||
if (pendingBlockTasks.size > 0) {
|
if (blockReplyPipeline) {
|
||||||
await Promise.allSettled(pendingBlockTasks);
|
await blockReplyPipeline.flush({ force: true });
|
||||||
|
blockReplyPipeline.stop();
|
||||||
}
|
}
|
||||||
if (pendingToolTasks.size > 0) {
|
if (pendingToolTasks.size > 0) {
|
||||||
await Promise.allSettled(pendingToolTasks);
|
await Promise.allSettled(pendingToolTasks);
|
||||||
@@ -736,7 +667,9 @@ export async function runReplyAgent(params: {
|
|||||||
// Drop final payloads only when block streaming succeeded end-to-end.
|
// Drop final payloads only when block streaming succeeded end-to-end.
|
||||||
// If streaming aborted (e.g., timeout), fall back to final payloads.
|
// If streaming aborted (e.g., timeout), fall back to final payloads.
|
||||||
const shouldDropFinalPayloads =
|
const shouldDropFinalPayloads =
|
||||||
blockStreamingEnabled && didStreamBlockReply && !blockReplyAborted;
|
blockStreamingEnabled &&
|
||||||
|
Boolean(blockReplyPipeline?.didStream()) &&
|
||||||
|
!blockReplyPipeline?.isAborted();
|
||||||
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
||||||
const messagingToolSentTargets = runResult.messagingToolSentTargets ?? [];
|
const messagingToolSentTargets = runResult.messagingToolSentTargets ?? [];
|
||||||
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
||||||
@@ -753,7 +686,7 @@ export async function runReplyAgent(params: {
|
|||||||
? []
|
? []
|
||||||
: blockStreamingEnabled
|
: blockStreamingEnabled
|
||||||
? dedupedPayloads.filter(
|
? dedupedPayloads.filter(
|
||||||
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
|
(payload) => !blockReplyPipeline?.hasSentPayload(payload),
|
||||||
)
|
)
|
||||||
: dedupedPayloads;
|
: dedupedPayloads;
|
||||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
||||||
@@ -886,6 +819,7 @@ export async function runReplyAgent(params: {
|
|||||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
blockReplyPipeline?.stop();
|
||||||
typing.markRunComplete();
|
typing.markRunComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
src/auto-reply/reply/block-reply-coalescer.test.ts
Normal file
71
src/auto-reply/reply/block-reply-coalescer.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||||
|
|
||||||
|
describe("block reply coalescer", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces chunks within the idle window", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const flushes: string[] = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push(payload.text ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "Hello" });
|
||||||
|
coalescer.enqueue({ text: "world" });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(flushes).toEqual(["Hello world"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits until minChars before idle flush", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const flushes: string[] = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push(payload.text ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "short" });
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(flushes).toEqual([]);
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "message" });
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(flushes).toEqual(["short message"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes buffered text before media payloads", () => {
|
||||||
|
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push({
|
||||||
|
text: payload.text,
|
||||||
|
mediaUrls: payload.mediaUrls,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "Hello" });
|
||||||
|
coalescer.enqueue({ text: "world" });
|
||||||
|
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
||||||
|
void coalescer.flush({ force: true });
|
||||||
|
|
||||||
|
expect(flushes[0].text).toBe("Hello world");
|
||||||
|
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
125
src/auto-reply/reply/block-reply-coalescer.ts
Normal file
125
src/auto-reply/reply/block-reply-coalescer.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
import type { BlockStreamingCoalescing } from "./block-streaming.js";
|
||||||
|
|
||||||
|
export type BlockReplyCoalescer = {
|
||||||
|
enqueue: (payload: ReplyPayload) => void;
|
||||||
|
flush: (options?: { force?: boolean }) => Promise<void>;
|
||||||
|
hasBuffered: () => boolean;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBlockReplyCoalescer(params: {
|
||||||
|
config: BlockStreamingCoalescing;
|
||||||
|
shouldAbort: () => boolean;
|
||||||
|
onFlush: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
|
}): BlockReplyCoalescer {
|
||||||
|
const { config, shouldAbort, onFlush } = params;
|
||||||
|
const minChars = Math.max(1, Math.floor(config.minChars));
|
||||||
|
const maxChars = Math.max(minChars, Math.floor(config.maxChars));
|
||||||
|
const idleMs = Math.max(0, Math.floor(config.idleMs));
|
||||||
|
const joiner = config.joiner ?? "";
|
||||||
|
|
||||||
|
let bufferText = "";
|
||||||
|
let bufferReplyToId: ReplyPayload["replyToId"];
|
||||||
|
let bufferAudioAsVoice: ReplyPayload["audioAsVoice"];
|
||||||
|
let idleTimer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
const clearIdleTimer = () => {
|
||||||
|
if (!idleTimer) return;
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetBuffer = () => {
|
||||||
|
bufferText = "";
|
||||||
|
bufferReplyToId = undefined;
|
||||||
|
bufferAudioAsVoice = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleIdleFlush = () => {
|
||||||
|
if (idleMs <= 0) return;
|
||||||
|
clearIdleTimer();
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
void flush({ force: false });
|
||||||
|
}, idleMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = async (options?: { force?: boolean }) => {
|
||||||
|
clearIdleTimer();
|
||||||
|
if (shouldAbort()) {
|
||||||
|
resetBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bufferText) return;
|
||||||
|
if (!options?.force && bufferText.length < minChars) {
|
||||||
|
scheduleIdleFlush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: ReplyPayload = {
|
||||||
|
text: bufferText,
|
||||||
|
replyToId: bufferReplyToId,
|
||||||
|
audioAsVoice: bufferAudioAsVoice,
|
||||||
|
};
|
||||||
|
resetBuffer();
|
||||||
|
await onFlush(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueue = (payload: ReplyPayload) => {
|
||||||
|
if (shouldAbort()) return;
|
||||||
|
const hasMedia =
|
||||||
|
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
|
const text = payload.text ?? "";
|
||||||
|
const hasText = text.trim().length > 0;
|
||||||
|
if (hasMedia) {
|
||||||
|
void flush({ force: true });
|
||||||
|
void onFlush(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasText) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
bufferText &&
|
||||||
|
(bufferReplyToId !== payload.replyToId ||
|
||||||
|
bufferAudioAsVoice !== payload.audioAsVoice)
|
||||||
|
) {
|
||||||
|
void flush({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bufferText) {
|
||||||
|
bufferReplyToId = payload.replyToId;
|
||||||
|
bufferAudioAsVoice = payload.audioAsVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextText = bufferText ? `${bufferText}${joiner}${text}` : text;
|
||||||
|
if (nextText.length > maxChars) {
|
||||||
|
if (bufferText) {
|
||||||
|
void flush({ force: true });
|
||||||
|
bufferReplyToId = payload.replyToId;
|
||||||
|
bufferAudioAsVoice = payload.audioAsVoice;
|
||||||
|
if (text.length >= maxChars) {
|
||||||
|
void onFlush(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bufferText = text;
|
||||||
|
scheduleIdleFlush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void onFlush(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bufferText = nextText;
|
||||||
|
if (bufferText.length >= maxChars) {
|
||||||
|
void flush({ force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleIdleFlush();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue,
|
||||||
|
flush,
|
||||||
|
hasBuffered: () => Boolean(bufferText),
|
||||||
|
stop: () => clearIdleTimer(),
|
||||||
|
};
|
||||||
|
}
|
||||||
173
src/auto-reply/reply/block-reply-pipeline.ts
Normal file
173
src/auto-reply/reply/block-reply-pipeline.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||||
|
import type { BlockStreamingCoalescing } from "./block-streaming.js";
|
||||||
|
|
||||||
|
export type BlockReplyPipeline = {
|
||||||
|
enqueue: (payload: ReplyPayload) => void;
|
||||||
|
flush: (options?: { force?: boolean }) => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
hasBuffered: () => boolean;
|
||||||
|
didStream: () => boolean;
|
||||||
|
isAborted: () => boolean;
|
||||||
|
hasSentPayload: (payload: ReplyPayload) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
|
||||||
|
const text = payload.text?.trim() ?? "";
|
||||||
|
const mediaList = payload.mediaUrls?.length
|
||||||
|
? payload.mediaUrls
|
||||||
|
: payload.mediaUrl
|
||||||
|
? [payload.mediaUrl]
|
||||||
|
: [];
|
||||||
|
return JSON.stringify({
|
||||||
|
text,
|
||||||
|
mediaList,
|
||||||
|
replyToId: payload.replyToId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const withTimeout = async <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutError: Error,
|
||||||
|
): Promise<T> => {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return promise;
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(timeoutError), timeoutMs);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createBlockReplyPipeline(params: {
|
||||||
|
onBlockReply: (
|
||||||
|
payload: ReplyPayload,
|
||||||
|
options?: { abortSignal?: AbortSignal; timeoutMs?: number },
|
||||||
|
) => Promise<void> | void;
|
||||||
|
timeoutMs: number;
|
||||||
|
coalescing?: BlockStreamingCoalescing;
|
||||||
|
}): BlockReplyPipeline {
|
||||||
|
const { onBlockReply, timeoutMs, coalescing } = params;
|
||||||
|
const sentKeys = new Set<string>();
|
||||||
|
const pendingKeys = new Set<string>();
|
||||||
|
const seenKeys = new Set<string>();
|
||||||
|
const bufferedKeys = new Set<string>();
|
||||||
|
let sendChain: Promise<void> = Promise.resolve();
|
||||||
|
let aborted = false;
|
||||||
|
let didStream = false;
|
||||||
|
let didLogTimeout = false;
|
||||||
|
|
||||||
|
const sendPayload = (payload: ReplyPayload, skipSeen?: boolean) => {
|
||||||
|
if (aborted) return;
|
||||||
|
const payloadKey = createBlockReplyPayloadKey(payload);
|
||||||
|
if (!skipSeen) {
|
||||||
|
if (seenKeys.has(payloadKey)) return;
|
||||||
|
seenKeys.add(payloadKey);
|
||||||
|
}
|
||||||
|
if (sentKeys.has(payloadKey) || pendingKeys.has(payloadKey)) return;
|
||||||
|
pendingKeys.add(payloadKey);
|
||||||
|
|
||||||
|
const timeoutError = new Error(
|
||||||
|
`block reply delivery timed out after ${timeoutMs}ms`,
|
||||||
|
);
|
||||||
|
const abortController = new AbortController();
|
||||||
|
sendChain = sendChain
|
||||||
|
.then(async () => {
|
||||||
|
if (aborted) return false;
|
||||||
|
await withTimeout(
|
||||||
|
onBlockReply(payload, {
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
timeoutMs,
|
||||||
|
}) ?? Promise.resolve(),
|
||||||
|
timeoutMs,
|
||||||
|
timeoutError,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.then((didSend) => {
|
||||||
|
if (!didSend) return;
|
||||||
|
sentKeys.add(payloadKey);
|
||||||
|
didStream = true;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err === timeoutError) {
|
||||||
|
abortController.abort();
|
||||||
|
aborted = true;
|
||||||
|
if (!didLogTimeout) {
|
||||||
|
didLogTimeout = true;
|
||||||
|
logVerbose(
|
||||||
|
`block reply delivery timed out after ${timeoutMs}ms; skipping remaining block replies to preserve ordering`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logVerbose(`block reply delivery failed: ${String(err)}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingKeys.delete(payloadKey);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const coalescer = coalescing
|
||||||
|
? createBlockReplyCoalescer({
|
||||||
|
config: coalescing,
|
||||||
|
shouldAbort: () => aborted,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
bufferedKeys.clear();
|
||||||
|
sendPayload(payload);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const enqueue = (payload: ReplyPayload) => {
|
||||||
|
if (aborted) return;
|
||||||
|
const hasMedia =
|
||||||
|
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
|
if (hasMedia) {
|
||||||
|
void coalescer?.flush({ force: true });
|
||||||
|
sendPayload(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coalescer) {
|
||||||
|
const payloadKey = createBlockReplyPayloadKey(payload);
|
||||||
|
if (
|
||||||
|
seenKeys.has(payloadKey) ||
|
||||||
|
pendingKeys.has(payloadKey) ||
|
||||||
|
bufferedKeys.has(payloadKey)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bufferedKeys.add(payloadKey);
|
||||||
|
coalescer.enqueue(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendPayload(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = async (options?: { force?: boolean }) => {
|
||||||
|
await coalescer?.flush(options);
|
||||||
|
await sendChain;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
coalescer?.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue,
|
||||||
|
flush,
|
||||||
|
stop,
|
||||||
|
hasBuffered: () => Boolean(coalescer?.hasBuffered()),
|
||||||
|
didStream: () => didStream,
|
||||||
|
isAborted: () => aborted,
|
||||||
|
hasSentPayload: (payload) => {
|
||||||
|
const payloadKey = createBlockReplyPayloadKey(payload);
|
||||||
|
return sentKeys.has(payloadKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||||
|
|
||||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||||
|
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 400;
|
||||||
|
|
||||||
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
|
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
@@ -12,6 +14,7 @@ const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
|
|||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
"webchat",
|
"webchat",
|
||||||
|
"msteams",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeChunkProvider(
|
function normalizeChunkProvider(
|
||||||
@@ -24,16 +27,24 @@ function normalizeChunkProvider(
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BlockStreamingCoalescing = {
|
||||||
|
minChars: number;
|
||||||
|
maxChars: number;
|
||||||
|
idleMs: number;
|
||||||
|
joiner: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveBlockStreamingChunking(
|
export function resolveBlockStreamingChunking(
|
||||||
cfg: ClawdbotConfig | undefined,
|
cfg: ClawdbotConfig | undefined,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
|
accountId?: string | null,
|
||||||
): {
|
): {
|
||||||
minChars: number;
|
minChars: number;
|
||||||
maxChars: number;
|
maxChars: number;
|
||||||
breakPreference: "paragraph" | "newline" | "sentence";
|
breakPreference: "paragraph" | "newline" | "sentence";
|
||||||
} {
|
} {
|
||||||
const providerKey = normalizeChunkProvider(provider);
|
const providerKey = normalizeChunkProvider(provider);
|
||||||
const textLimit = resolveTextChunkLimit(cfg, providerKey);
|
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId);
|
||||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||||
const maxRequested = Math.max(
|
const maxRequested = Math.max(
|
||||||
1,
|
1,
|
||||||
@@ -52,3 +63,88 @@ export function resolveBlockStreamingChunking(
|
|||||||
: "paragraph";
|
: "paragraph";
|
||||||
return { minChars, maxChars, breakPreference };
|
return { minChars, maxChars, breakPreference };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveBlockStreamingCoalescing(
|
||||||
|
cfg: ClawdbotConfig | undefined,
|
||||||
|
provider?: string,
|
||||||
|
accountId?: string | null,
|
||||||
|
chunking?: {
|
||||||
|
minChars: number;
|
||||||
|
maxChars: number;
|
||||||
|
breakPreference: "paragraph" | "newline" | "sentence";
|
||||||
|
},
|
||||||
|
): BlockStreamingCoalescing {
|
||||||
|
const providerKey = normalizeChunkProvider(provider);
|
||||||
|
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId);
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
const providerCfg = (() => {
|
||||||
|
if (!cfg || !providerKey) return undefined;
|
||||||
|
if (providerKey === "whatsapp") {
|
||||||
|
return (
|
||||||
|
cfg.whatsapp?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
|
||||||
|
cfg.whatsapp?.blockStreamingCoalesce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (providerKey === "telegram") {
|
||||||
|
return (
|
||||||
|
cfg.telegram?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
|
||||||
|
cfg.telegram?.blockStreamingCoalesce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (providerKey === "discord") {
|
||||||
|
return (
|
||||||
|
cfg.discord?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
|
||||||
|
cfg.discord?.blockStreamingCoalesce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (providerKey === "slack") {
|
||||||
|
return (
|
||||||
|
cfg.slack?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
|
||||||
|
cfg.slack?.blockStreamingCoalesce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (providerKey === "signal") {
|
||||||
|
return (
|
||||||
|
cfg.signal?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
|
||||||
|
cfg.signal?.blockStreamingCoalesce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (providerKey === "imessage") {
|
||||||
|
return (
|
||||||
|
cfg.imessage?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ??
|
||||||
|
cfg.imessage?.blockStreamingCoalesce
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (providerKey === "msteams") {
|
||||||
|
return cfg.msteams?.blockStreamingCoalesce;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
const coalesceCfg =
|
||||||
|
providerCfg ?? cfg?.agents?.defaults?.blockStreamingCoalesce;
|
||||||
|
const minRequested = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
coalesceCfg?.minChars ?? chunking?.minChars ?? DEFAULT_BLOCK_STREAM_MIN,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const maxRequested = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(coalesceCfg?.maxChars ?? textLimit),
|
||||||
|
);
|
||||||
|
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||||
|
const minChars = Math.min(minRequested, maxChars);
|
||||||
|
const idleMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(coalesceCfg?.idleMs ?? DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS),
|
||||||
|
);
|
||||||
|
const preference = chunking?.breakPreference ?? "paragraph";
|
||||||
|
const joiner =
|
||||||
|
preference === "sentence" ? " " : preference === "newline" ? "\n" : "\n\n";
|
||||||
|
return {
|
||||||
|
minChars,
|
||||||
|
maxChars,
|
||||||
|
idleMs,
|
||||||
|
joiner,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export type OutboundRetryConfig = {
|
|||||||
jitter?: number;
|
jitter?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BlockStreamingCoalesceConfig = {
|
||||||
|
minChars?: number;
|
||||||
|
maxChars?: number;
|
||||||
|
idleMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionSendPolicyAction = "allow" | "deny";
|
export type SessionSendPolicyAction = "allow" | "deny";
|
||||||
export type SessionSendPolicyMatch = {
|
export type SessionSendPolicyMatch = {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -127,6 +133,8 @@ export type WhatsAppConfig = {
|
|||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/** Per-action tool gating (default: true for all). */
|
/** Per-action tool gating (default: true for all). */
|
||||||
actions?: WhatsAppActionConfig;
|
actions?: WhatsAppActionConfig;
|
||||||
groups?: Record<
|
groups?: Record<
|
||||||
@@ -153,6 +161,8 @@ export type WhatsAppAccountConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
groups?: Record<
|
groups?: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -306,6 +316,8 @@ export type TelegramAccountConfig = {
|
|||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
|
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
|
||||||
streamMode?: "off" | "partial" | "block";
|
streamMode?: "off" | "partial" | "block";
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
@@ -429,6 +441,8 @@ export type DiscordAccountConfig = {
|
|||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/**
|
/**
|
||||||
* Soft max line count per Discord message.
|
* Soft max line count per Discord message.
|
||||||
* Discord clients can clip/collapse very tall messages; splitting by lines
|
* Discord clients can clip/collapse very tall messages; splitting by lines
|
||||||
@@ -525,6 +539,8 @@ export type SlackAccountConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||||
reactionNotifications?: SlackReactionNotificationMode;
|
reactionNotifications?: SlackReactionNotificationMode;
|
||||||
@@ -579,6 +595,8 @@ export type SignalAccountConfig = {
|
|||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -632,6 +650,8 @@ export type MSTeamsConfig = {
|
|||||||
allowFrom?: Array<string>;
|
allowFrom?: Array<string>;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/**
|
/**
|
||||||
* Allowed host suffixes for inbound attachment downloads.
|
* Allowed host suffixes for inbound attachment downloads.
|
||||||
* Use ["*"] to allow any host (not recommended).
|
* Use ["*"] to allow any host (not recommended).
|
||||||
@@ -678,6 +698,8 @@ export type IMessageAccountConfig = {
|
|||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
groups?: Record<
|
groups?: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -1201,6 +1223,11 @@ export type AgentDefaultsConfig = {
|
|||||||
maxChars?: number;
|
maxChars?: number;
|
||||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Block reply coalescing (merge streamed chunks before send).
|
||||||
|
* idleMs: wait time before flushing when idle.
|
||||||
|
*/
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
|
|||||||
|
|
||||||
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
|
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
|
||||||
|
|
||||||
|
const BlockStreamingCoalesceSchema = z.object({
|
||||||
|
minChars: z.number().int().positive().optional(),
|
||||||
|
maxChars: z.number().int().positive().optional(),
|
||||||
|
idleMs: z.number().int().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
|
const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
|
||||||
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
|
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -192,6 +198,7 @@ const TelegramAccountSchemaBase = z.object({
|
|||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
|
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
retry: RetryConfigSchema,
|
retry: RetryConfigSchema,
|
||||||
@@ -277,6 +284,7 @@ const DiscordAccountSchema = z.object({
|
|||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
maxLinesPerMessage: z.number().int().positive().optional(),
|
maxLinesPerMessage: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
@@ -347,6 +355,7 @@ const SlackAccountSchema = z.object({
|
|||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
@@ -398,6 +407,7 @@ const SignalAccountSchemaBase = z.object({
|
|||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().int().positive().optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,6 +453,7 @@ const IMessageAccountSchemaBase = z.object({
|
|||||||
mediaMaxMb: z.number().int().positive().optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
groups: z
|
groups: z
|
||||||
.record(
|
.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
@@ -507,6 +518,7 @@ const MSTeamsConfigSchema = z
|
|||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaAllowHosts: z.array(z.string()).optional(),
|
mediaAllowHosts: z.array(z.string()).optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||||
@@ -994,6 +1006,7 @@ const AgentDefaultsSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
@@ -1215,6 +1228,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
groups: z
|
groups: z
|
||||||
.record(
|
.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
@@ -1249,6 +1263,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
actions: z
|
actions: z
|
||||||
.object({
|
.object({
|
||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const draftChunking =
|
const draftChunking =
|
||||||
draftStream && streamMode === "block"
|
draftStream && streamMode === "block"
|
||||||
? resolveBlockStreamingChunking(cfg, "telegram")
|
? resolveBlockStreamingChunking(cfg, "telegram", route.accountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const draftChunker = draftChunking
|
const draftChunker = draftChunking
|
||||||
? new EmbeddedBlockChunker(draftChunking)
|
? new EmbeddedBlockChunker(draftChunking)
|
||||||
|
|||||||
Reference in New Issue
Block a user