Files
openclaw/extensions/discord/src/monitor/thread-title.ts
Hana Chang 537479f5b0 fix(discord): raise thread title max tokens for reasoning models
When the simple-completion model selected for thread-title generation is a
reasoning model (e.g. MiniMax M2, Claude thinking models, OpenAI o-series),
the 24-token output budget is entirely consumed by the internal thinking
block before any user-visible text is emitted. extractAssistantText then
returns an empty string, generateThreadTitle returns null, and the
auto-thread rename is silently skipped while the feature appears to do
nothing.

Raise DISCORD_THREAD_TITLE_MAX_TOKENS to 512 so there is enough headroom
for a short thinking pass plus the 3-6 word title output. The generous
ceiling only matters when the provider actually reasons; non-reasoning
models still emit a short title and stop early at end-of-sequence.

Verified live against a MiniMax M2 reasoning model served through an
Anthropic-compatible API endpoint: before the fix, the rename never fired;
after the fix, the thread is renamed with a concise generated title.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:07:22 +01:00

182 lines
5.8 KiB
TypeScript

import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
completeWithPreparedSimpleCompletionModel,
extractAssistantText,
prepareSimpleCompletionModelForAgent,
} from "openclaw/plugin-sdk/simple-completion-runtime";
const DEFAULT_THREAD_TITLE_TIMEOUT_MS = 10_000;
const MAX_THREAD_TITLE_SOURCE_CHARS = 600;
const MAX_THREAD_TITLE_CHANNEL_NAME_CHARS = 120;
const MAX_THREAD_TITLE_CHANNEL_DESCRIPTION_CHARS = 320;
// Budget generous enough to cover reasoning-model thinking tokens plus the
// short text output. Lower values (e.g. 24) starve reasoning models of output
// capacity: the entire budget is consumed by the thinking block before any
// text is emitted, so extractAssistantText returns empty and the rename is
// silently skipped.
const DISCORD_THREAD_TITLE_MAX_TOKENS = 512;
const DISCORD_THREAD_TITLE_SYSTEM_PROMPT =
"Generate a concise Discord thread title (3-6 words). Return only the title. Use channel context when provided and avoid redundant channel-name words unless needed for clarity.";
export async function generateThreadTitle(params: {
cfg: OpenClawConfig;
agentId: string;
messageText: string;
modelRef?: string;
channelName?: string;
channelDescription?: string;
timeoutMs?: number;
}): Promise<string | null> {
const sourceText = params.messageText.trim();
if (!sourceText) {
return null;
}
const prepared = await prepareSimpleCompletionModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
...(params.modelRef ? { modelRef: params.modelRef } : {}),
allowMissingApiKeyModes: ["aws-sdk"],
});
if ("error" in prepared) {
const modelLabel = prepared.selection
? `${prepared.selection.provider}/${prepared.selection.modelId}`
: "unknown";
logVerbose(`thread-title: ${prepared.error} (agent=${params.agentId}, model=${modelLabel})`);
return null;
}
try {
const promptText = truncateThreadTitleSourceText(sourceText);
const userMessage = buildThreadTitleUserMessage({
sourceText: promptText,
channelName: params.channelName,
channelDescription: params.channelDescription,
});
const timeoutMs = resolveThreadTitleTimeoutMs(params.timeoutMs);
const response = await completeThreadTitle({
model: prepared.model,
auth: prepared.auth,
userMessage,
timeoutMs,
});
const generated = normalizeGeneratedThreadTitle(extractAssistantText(response));
return generated || null;
} catch (err) {
logVerbose(`thread-title: title generation failed for agent ${params.agentId}: ${String(err)}`);
return null;
}
}
async function completeThreadTitle(params: {
model: Parameters<typeof completeWithPreparedSimpleCompletionModel>[0]["model"];
auth: Parameters<typeof completeWithPreparedSimpleCompletionModel>[0]["auth"];
userMessage: string;
timeoutMs: number;
}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
try {
return await completeWithPreparedSimpleCompletionModel({
model: params.model,
auth: params.auth,
context: {
systemPrompt: DISCORD_THREAD_TITLE_SYSTEM_PROMPT,
messages: [
{
role: "user",
content: params.userMessage,
timestamp: Date.now(),
},
],
},
options: {
maxTokens: DISCORD_THREAD_TITLE_MAX_TOKENS,
signal: controller.signal,
},
});
} finally {
clearTimeout(timer);
}
}
function buildThreadTitleUserMessage(params: {
sourceText: string;
channelName?: string;
channelDescription?: string;
}): string {
const channelName = normalizeTitleContextField(
params.channelName,
MAX_THREAD_TITLE_CHANNEL_NAME_CHARS,
);
const channelDescription = normalizeTitleContextField(
params.channelDescription,
MAX_THREAD_TITLE_CHANNEL_DESCRIPTION_CHARS,
);
const messageLines: string[] = [];
if (channelName) {
messageLines.push(`Channel: ${channelName}`);
}
if (channelDescription) {
messageLines.push(`Channel description: ${channelDescription}`);
}
messageLines.push(`Message:\n${params.sourceText}`);
return messageLines.join("\n\n");
}
function truncateThreadTitleSourceText(sourceText: string): string {
if (sourceText.length <= MAX_THREAD_TITLE_SOURCE_CHARS) {
return sourceText;
}
return `${sourceText.slice(0, MAX_THREAD_TITLE_SOURCE_CHARS)}...`;
}
function resolveThreadTitleTimeoutMs(timeoutMs: number | undefined): number {
return Math.max(100, Math.floor(timeoutMs ?? DEFAULT_THREAD_TITLE_TIMEOUT_MS));
}
export function normalizeGeneratedThreadTitle(raw: string): string {
const lines = raw.replace(/\r/g, "").split("\n");
let firstLine = "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (!firstLine && trimmed.startsWith("```")) {
continue;
}
firstLine = trimmed;
break;
}
return stripThreadTitleWrappers(firstLine);
}
function stripThreadTitleWrappers(raw: string): string {
let current = raw.trim();
let previous = "";
while (current && current !== previous) {
previous = current;
current = current.replace(/^["'`]+|["'`]+$/g, "").trim();
current = current.replace(/^\*\*(.+)\*\*$/u, "$1").trim();
current = current.replace(/^__(.+)__$/u, "$1").trim();
current = current.replace(/^\*(.+)\*$/u, "$1").trim();
current = current.replace(/^_(.+)_$/u, "$1").trim();
current = current.replace(/^~~(.+)~~$/u, "$1").trim();
}
return current;
}
function normalizeTitleContextField(raw: string | undefined, maxChars: number): string | undefined {
const value = raw?.trim();
if (!value) {
return undefined;
}
const singleLine = value.replace(/\s+/g, " ");
if (singleLine.length <= maxChars) {
return singleLine;
}
return `${singleLine.slice(0, maxChars)}...`;
}