From 069a00e15e4dafa0685ca8ce20e63218b9b415fb Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Fri, 23 Jan 2026 13:03:36 -0800 Subject: [PATCH] feat: add optional conversation title generation on conversation creation #981 --- .changeset/ten-lines-repair.md | 21 +++ examples/base/src/index.ts | 1 + packages/core/src/agent/agent.ts | 147 +++++++++++++++++- packages/core/src/agent/types.ts | 1 + packages/core/src/memory/index.ts | 9 ++ .../src/memory/manager/memory-manager.spec.ts | 26 ++++ .../core/src/memory/manager/memory-manager.ts | 52 ++++++- packages/core/src/memory/types.ts | 23 ++- website/docs/agents/memory/overview.md | 36 +++++ website/docs/api/endpoints/memory.md | 2 + 10 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 .changeset/ten-lines-repair.md diff --git a/.changeset/ten-lines-repair.md b/.changeset/ten-lines-repair.md new file mode 100644 index 000000000..2d04ba490 --- /dev/null +++ b/.changeset/ten-lines-repair.md @@ -0,0 +1,21 @@ +--- +"@voltagent/core": patch +--- + +feat: add optional conversation title generation on conversation creation. Titles are derived from the first user message, respect a max length, and can use the agent model or a configured override. #981 + +```ts +import { Memory } from "@voltagent/core"; +import { LibSQLMemoryAdapter } from "@voltagent/libsql"; + +const memory = new Memory({ + storage: new LibSQLMemoryAdapter({ url: "file:./.voltagent/memory.db" }), + generateTitle: { + enabled: true, + model: "gpt-4o-mini", // defaults to the agent model when omitted + systemPrompt: "Generate a short title (max 6 words).", + maxLength: 60, + maxOutputTokens: 24, + }, +}); +``` diff --git a/examples/base/src/index.ts b/examples/base/src/index.ts index ff58a0c20..2b28cbe1d 100644 --- a/examples/base/src/index.ts +++ b/examples/base/src/index.ts @@ -14,6 +14,7 @@ const memory = new Memory({ storage: new LibSQLMemoryAdapter(), embedding: "openai/text-embedding-3-small", vector: new LibSQLVectorAdapter(), + generateTitle: true, }); const agent = new Agent({ diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 5423428e7..6d078ff1e 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -44,8 +44,10 @@ import { import { z } from "zod"; import { LogEvents, LoggerProxy } from "../logger"; import { ActionType, buildAgentLogMessage } from "../logger/message-builder"; -import type { Memory, MemoryUpdateMode } from "../memory"; +import { Memory } from "../memory"; +import type { MemoryUpdateMode } from "../memory"; import { MemoryManager } from "../memory/manager/memory-manager"; +import type { ConversationTitleConfig, ConversationTitleGenerator } from "../memory/types"; import { type VoltAgentObservability, createVoltAgentObservability } from "../observability"; import { TRIGGER_CONTEXT_KEY } from "../observability/context-keys"; import { type ObservabilityFlushState, flushObservability } from "../observability/utils"; @@ -171,6 +173,14 @@ const STEP_PERSIST_COUNT_KEY = Symbol("persistedStepCount"); const ABORT_LISTENER_ATTACHED_KEY = Symbol("abortListenerAttached"); const MIDDLEWARE_RETRY_FEEDBACK_KEY = Symbol("middlewareRetryFeedback"); const DEFAULT_FEEDBACK_KEY = "satisfaction"; +const DEFAULT_CONVERSATION_TITLE_PROMPT = [ + "You generate concise titles for new conversations.", + "Summarize the user's first message in a short phrase.", + "Keep it under 80 characters and return only the title.", +].join("\n"); +const DEFAULT_CONVERSATION_TITLE_MAX_OUTPUT_TOKENS = 32; +const DEFAULT_CONVERSATION_TITLE_MAX_CHARS = 80; +const CONVERSATION_TITLE_INPUT_MAX_CHARS = 2000; // ============================================================================ // Types @@ -192,6 +202,18 @@ function toContextMap(context?: ContextInput): Map | u return context instanceof Map ? context : new Map(Object.entries(context)); } +function sanitizeConversationTitle(text: string, maxLength: number): string { + const trimmed = text.replace(/\s+/g, " ").trim(); + if (!trimmed) return ""; + + const unquoted = trimmed.replace(/^["'`]+|["'`]+$/g, ""); + if (!Number.isFinite(maxLength) || maxLength <= 0) { + return unquoted; + } + + return unquoted.length > maxLength ? unquoted.slice(0, maxLength).trim() : unquoted; +} + /** * Agent context with comprehensive tracking */ @@ -267,7 +289,12 @@ export interface GenerateTextResultWithContext< feedback?: AgentFeedbackMetadata | null; } -type LLMOperation = "streamText" | "generateText" | "streamObject" | "generateObject"; +type LLMOperation = + | "streamText" + | "generateText" + | "streamObject" + | "generateObject" + | "generateTitle"; /** * Extended GenerateObjectResult that includes context @@ -579,7 +606,16 @@ export class Agent { const resolvedMemory = this.memoryConfigured ? options.memory : AgentRegistry.getInstance().getGlobalAgentMemory(); - this.memoryManager = new MemoryManager(this.id, resolvedMemory, {}, this.logger); + const titleGenerator = this.createConversationTitleGenerator( + resolvedMemory instanceof Memory ? resolvedMemory : undefined, + ); + this.memoryManager = new MemoryManager( + this.id, + resolvedMemory, + {}, + this.logger, + titleGenerator, + ); // Initialize tool manager with static tools const staticTools = typeof options.tools === "function" ? [] : options.tools; @@ -3153,11 +3189,14 @@ export class Agent { tools?: ToolSet; providerOptions?: ProviderOptions; callOptions?: Record; + label?: string; }, ): Span { - const attributes = this.buildLLMSpanAttributes(params); + const { label, ...spanParams } = params; + const attributes = this.buildLLMSpanAttributes(spanParams); const span = oc.traceContext.createChildSpan(`llm:${params.operation}`, "llm", { kind: SpanKind.CLIENT, + label, attributes, }); return span; @@ -3481,6 +3520,106 @@ export class Agent { return undefined; } + private createConversationTitleGenerator( + memory?: Memory, + ): ConversationTitleGenerator | undefined { + const rawConfig = memory?.getTitleGenerationConfig?.(); + if (!rawConfig) { + return undefined; + } + + const normalized: ConversationTitleConfig = + typeof rawConfig === "boolean" ? { enabled: rawConfig } : { ...rawConfig }; + const enabled = normalized.enabled ?? true; + if (!enabled) { + return undefined; + } + + const systemPrompt = + normalized.systemPrompt === undefined + ? DEFAULT_CONVERSATION_TITLE_PROMPT + : (normalized.systemPrompt ?? ""); + const maxOutputTokens = + typeof normalized.maxOutputTokens === "number" && Number.isFinite(normalized.maxOutputTokens) + ? Math.max(1, normalized.maxOutputTokens) + : DEFAULT_CONVERSATION_TITLE_MAX_OUTPUT_TOKENS; + const maxLength = + typeof normalized.maxLength === "number" && Number.isFinite(normalized.maxLength) + ? Math.max(1, normalized.maxLength) + : DEFAULT_CONVERSATION_TITLE_MAX_CHARS; + + const modelOverride = normalized.model; + + return async ({ input, context }) => { + const inputForQuery = typeof input === "string" || Array.isArray(input) ? input : [input]; + const query = this.extractUserQuery(inputForQuery as string | UIMessage[] | BaseMessage[]); + const trimmed = query?.trim(); + if (!trimmed) { + return null; + } + + const limitedInput = + trimmed.length > CONVERSATION_TITLE_INPUT_MAX_CHARS + ? trimmed.slice(0, CONVERSATION_TITLE_INPUT_MAX_CHARS) + : trimmed; + + try { + const resolvedModel = await this.resolveModel(modelOverride ?? this.model, context); + const messages: Array<{ role: "system" | "user"; content: string }> = []; + if (systemPrompt.trim()) { + messages.push({ role: "system", content: systemPrompt }); + } + messages.push({ role: "user", content: limitedInput }); + const modelName = this.getModelName(resolvedModel); + const llmSpan = this.createLLMSpan(context, { + operation: "generateTitle", + modelName, + isStreaming: false, + messages, + callOptions: { + temperature: 0, + maxOutputTokens, + }, + label: "Generate Conversation Title", + }); + llmSpan.setAttribute("input", limitedInput); + const finalizeLLMSpan = this.createLLMSpanFinalizer(llmSpan); + + try { + const result = await context.traceContext.withSpan(llmSpan, () => + generateText({ + model: resolvedModel, + messages, + temperature: 0, + maxOutputTokens, + abortSignal: context.abortController.signal, + }), + ); + + const resolvedUsage = result.usage ? await Promise.resolve(result.usage) : undefined; + const title = sanitizeConversationTitle(result.text ?? "", maxLength); + if (title) { + llmSpan.setAttribute("output", title); + } + finalizeLLMSpan(SpanStatusCode.OK, { + usage: resolvedUsage, + finishReason: result.finishReason, + }); + + return title || null; + } catch (error) { + finalizeLLMSpan(SpanStatusCode.ERROR, { message: (error as Error).message }); + throw error; + } + } catch (error) { + context.logger.debug("[Memory] Failed to generate conversation title", { + error: safeStringify(error), + }); + return null; + } + }; + } + /** * Prepare messages with system prompt and memory */ diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 1e2a4aa32..0e458af55 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -634,6 +634,7 @@ export type AgentOptions = { export type AgentEvalOperationType = | "generateText" + | "generateTitle" | "streamText" | "generateObject" | "streamObject" diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index 9c76978fa..e7a613967 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -77,6 +77,7 @@ export class Memory { private readonly vector?: VectorAdapter; private embeddingCache?: BatchEmbeddingCache; private readonly workingMemoryConfig?: WorkingMemoryConfig; + private readonly titleGenerationConfig?: MemoryConfig["generateTitle"]; // Internal properties for Agent integration private resourceId?: string; @@ -87,6 +88,7 @@ export class Memory { this.embedding = resolveEmbeddingAdapter(options.embedding); this.vector = options.vector; this.workingMemoryConfig = options.workingMemory; + this.titleGenerationConfig = options.generateTitle; // Initialize embedding cache if enabled if (options.enableCache && this.embedding) { @@ -1122,6 +1124,13 @@ Remember: return { adapter }; } + /** + * Get conversation title generation configuration + */ + getTitleGenerationConfig(): MemoryConfig["generateTitle"] | undefined { + return this.titleGenerationConfig; + } + /** * Get a UI-friendly summary of working memory configuration */ diff --git a/packages/core/src/memory/manager/memory-manager.spec.ts b/packages/core/src/memory/manager/memory-manager.spec.ts index 96dabefb6..3750e83d5 100644 --- a/packages/core/src/memory/manager/memory-manager.spec.ts +++ b/packages/core/src/memory/manager/memory-manager.spec.ts @@ -92,6 +92,32 @@ describe("MemoryManager", () => { expect(messages[0].id).toBe("msg-1"); }); + it("should generate a title when creating a conversation", async () => { + const context = createMockOperationContext(); + context.input = "Plan a weekend trip to Rome."; + + const titleGenerator = vi.fn().mockResolvedValue("Rome Weekend Plan"); + const managerWithTitle = new MemoryManager( + "agent-1", + memory, + {}, + getGlobalLogger().child({ test: true }), + titleGenerator, + ); + + const message = createTestUIMessage({ + id: "msg-1", + role: "assistant", + parts: [{ type: "text", text: "Sure, let's plan it." }], + }); + + await managerWithTitle.saveMessage(context, message, "user-1", "conv-title"); + + const conversation = await memory.getConversation("conv-title"); + expect(conversation?.title).toBe("Rome Weekend Plan"); + expect(titleGenerator).toHaveBeenCalledTimes(1); + }); + it("should handle errors gracefully", async () => { // Create manager with mocked memory that throws error const errorMemory = new Memory({ diff --git a/packages/core/src/memory/manager/memory-manager.ts b/packages/core/src/memory/manager/memory-manager.ts index 8d9e1b30b..287fb0248 100644 --- a/packages/core/src/memory/manager/memory-manager.ts +++ b/packages/core/src/memory/manager/memory-manager.ts @@ -19,7 +19,7 @@ import { InMemoryStorageAdapter } from "../../memory/adapters/storage/in-memory" // Import AgentTraceContext for proper span hierarchy import type { AgentTraceContext } from "../../agent/open-telemetry/trace-context"; -import type { ConversationStepRecord, MemoryOptions } from "../types"; +import type { ConversationStepRecord, ConversationTitleGenerator, MemoryOptions } from "../types"; /** * MemoryManager - Simplified version for conversation management only @@ -51,6 +51,11 @@ export class MemoryManager { */ private backgroundQueue: BackgroundQueue; + /** + * Optional title generator for new conversations + */ + private titleGenerator?: ConversationTitleGenerator; + /** * Creates a new MemoryManager V2 with same signature as original */ @@ -59,10 +64,12 @@ export class MemoryManager { memory?: Memory | false, options: MemoryOptions = {}, logger?: Logger, + titleGenerator?: ConversationTitleGenerator, ) { this.resourceId = resourceId; this.logger = logger || getGlobalLogger().child({ component: "memory-manager", resourceId }); this.options = options; + this.titleGenerator = titleGenerator; // Handle conversation memory if (memory === false) { @@ -129,11 +136,16 @@ export class MemoryManager { // Ensure conversation exists const conv = await this.conversationMemory?.getConversation(conversationId); if (!conv) { + const title = await this.resolveConversationTitle( + context, + context.input ?? messageWithMetadata, + "Conversation", + ); await this.conversationMemory?.createConversation({ id: conversationId, userId: userId, resourceId: this.resourceId, - title: "Conversation", + title, metadata: {}, }); } @@ -510,7 +522,7 @@ export class MemoryManager { operation: async () => { try { // First ensure conversation exists - await this.ensureConversationExists(context, userId, conversationId); + await this.ensureConversationExists(context, userId, conversationId, input); // Then save current input await this.saveCurrentInput(context, input, userId, conversationId); @@ -537,6 +549,36 @@ export class MemoryManager { this.handleSequentialBackgroundOperations(context, input, userId, conversationId); } + /** + * Resolve conversation title using optional generator + */ + private async resolveConversationTitle( + context: OperationContext, + input: OperationContext["input"] | UIMessage | undefined, + fallbackTitle: string, + ): Promise { + if (!this.titleGenerator || !input) { + return fallbackTitle; + } + + try { + const title = await this.titleGenerator({ + input, + context, + defaultTitle: fallbackTitle, + }); + if (typeof title === "string" && title.trim().length > 0) { + return title.trim(); + } + } catch (error) { + context.logger.debug("[Memory] Failed to generate conversation title", { + error: safeStringify(error), + }); + } + + return fallbackTitle; + } + /** * Ensure conversation exists (background task) * PRESERVED FROM ORIGINAL @@ -545,13 +587,15 @@ export class MemoryManager { context: OperationContext, userId: string, conversationId: string, + input?: OperationContext["input"] | UIMessage, ): Promise { if (!this.conversationMemory) return; try { const existingConversation = await this.conversationMemory.getConversation(conversationId); if (!existingConversation) { - const title = `New Chat ${new Date().toISOString()}`; + const defaultTitle = `New Chat ${new Date().toISOString()}`; + const title = await this.resolveConversationTitle(context, input, defaultTitle); try { await this.conversationMemory.createConversation({ id: conversationId, diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts index b043a4c60..29eaf83f2 100644 --- a/packages/core/src/memory/types.ts +++ b/packages/core/src/memory/types.ts @@ -6,7 +6,7 @@ import type { UIMessage } from "ai"; import type { z } from "zod"; import type { MessageRole, UsageInfo } from "../agent/providers/base/types"; -import type { OperationContext } from "../agent/types"; +import type { AgentModelValue, OperationContext } from "../agent/types"; import type { EmbeddingModelReference, EmbeddingOptions } from "./adapters/embedding/types"; // ============================================================================ @@ -101,6 +101,20 @@ export interface GetConversationStepsOptions { // biome-ignore lint/complexity/noBannedTypes: export type MemoryOptions = {}; +export type ConversationTitleConfig = { + enabled?: boolean; + model?: AgentModelValue; + maxOutputTokens?: number; + maxLength?: number; + systemPrompt?: string | null; +}; + +export type ConversationTitleGenerator = (params: { + input: OperationContext["input"] | UIMessage; + context: OperationContext; + defaultTitle: string; +}) => Promise; + // ============================================================================ // Workflow State Types // ============================================================================ @@ -259,6 +273,13 @@ export interface MemoryConfig { * Enables agents to maintain important context */ workingMemory?: WorkingMemoryConfig; + + /** + * Automatically generate a title for new conversations using the agent's model + * (or the override model if provided). + * @default false + */ + generateTitle?: boolean | ConversationTitleConfig; } /** diff --git a/website/docs/agents/memory/overview.md b/website/docs/agents/memory/overview.md index 9cae3f049..e19d48c02 100644 --- a/website/docs/agents/memory/overview.md +++ b/website/docs/agents/memory/overview.md @@ -26,6 +26,42 @@ VoltAgent's `Memory` class stores conversation history and optional semantic sea - Auto-creates conversations on first message - Configurable message limits (oldest pruned first) +### Conversation Titles (Optional) + +When enabled, VoltAgent generates a concise title from the first user message. Title generation runs only when the conversation is created and does not overwrite existing titles. + +```ts +import { Memory } from "@voltagent/core"; +import { LibSQLMemoryAdapter } from "@voltagent/libsql"; + +const memory = new Memory({ + storage: new LibSQLMemoryAdapter({ url: "file:./.voltagent/memory.db" }), + generateTitle: true, +}); +``` + +Custom configuration: + +```ts +const memory = new Memory({ + storage: new LibSQLMemoryAdapter({ url: "file:./.voltagent/memory.db" }), + generateTitle: { + enabled: true, + model: "gpt-4o-mini", // default agent model + systemPrompt: "Generate a short title (max 6 words).", + maxLength: 60, + maxOutputTokens: 24, + }, +}); +``` + +Notes: + +- The agent's main model is used unless `generateTitle.model` is provided. +- `generateTitle.model` accepts either a provider/model string or an AI SDK model instance. +- Only the first user message is summarized. +- If you create conversations manually via the Memory API, set `title` explicitly. + ### Conversation Steps - Every LLM/text/tool step can be recorded with metadata (operationId, agent/sub-agent IDs, usage, tool arguments/results). diff --git a/website/docs/api/endpoints/memory.md b/website/docs/api/endpoints/memory.md index c697f520e..b6510f852 100644 --- a/website/docs/api/endpoints/memory.md +++ b/website/docs/api/endpoints/memory.md @@ -73,6 +73,8 @@ curl "http://localhost:3141/api/memory/conversations/conv-001" } ``` +`title` is optional. If omitted, the server stores an empty title. Auto-title generation happens only when an agent creates the conversation with `generateTitle` enabled on `Memory`. + ## Update Conversation **Endpoint:** `PATCH /api/memory/conversations/:conversationId`