Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/ten-lines-repair.md
Original file line number Diff line number Diff line change
@@ -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,
},
});
```
1 change: 1 addition & 0 deletions examples/base/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
147 changes: 143 additions & 4 deletions packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -192,6 +202,18 @@ function toContextMap(context?: ContextInput): Map<string | symbol, unknown> | 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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3153,11 +3189,14 @@ export class Agent {
tools?: ToolSet;
providerOptions?: ProviderOptions;
callOptions?: Record<string, unknown>;
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;
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ export type AgentOptions = {

export type AgentEvalOperationType =
| "generateText"
| "generateTitle"
| "streamText"
| "generateObject"
| "streamObject"
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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
*/
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/memory/manager/memory-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading