mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 08:44:46 +00:00
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>
182 lines
5.8 KiB
TypeScript
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)}...`;
|
|
}
|