diff --git a/.changeset/generate-title-temperature-override.md b/.changeset/generate-title-temperature-override.md new file mode 100644 index 000000000..3f328c04d --- /dev/null +++ b/.changeset/generate-title-temperature-override.md @@ -0,0 +1,7 @@ +--- +"@voltagent/core": patch +--- + +fix(core): allow disabling conversation title temperature + +Conversation title generation now keeps the existing default `temperature: 0`, while allowing `generateTitle.temperature: null` to omit the parameter for reasoning models that do not support temperature. Unsupported temperature warnings are surfaced at warn level with guidance, and title generation failures are logged at warn level instead of debug. diff --git a/packages/core/src/agent/agent.spec.ts b/packages/core/src/agent/agent.spec.ts index 4108f566c..ca7bf4ac8 100644 --- a/packages/core/src/agent/agent.spec.ts +++ b/packages/core/src/agent/agent.spec.ts @@ -1907,6 +1907,330 @@ Use pandas and summarize findings.`.split("\n"), return [...messages].reverse().find((message) => message.role === "assistant"); }; + it("should use temperature 0 by default when generating conversation titles", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + generateTitle: true, + }); + const agent = new Agent({ + name: "TestAgent", + instructions: "Test", + model: mockModel as any, + memory, + }); + const span = { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + recordException: vi.fn(), + }; + const context = { + operationId: "test-operation-id", + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + }, + context: new Map(), + systemContext: new Map(), + isActive: true, + traceContext: { + createChildSpan: vi.fn().mockReturnValue(span), + withSpan: vi.fn().mockImplementation(async (_span, fn) => await fn()), + }, + abortController: new AbortController(), + startTime: new Date(), + }; + + vi.mocked(ai.generateText).mockResolvedValue({ + text: "Rome Weekend Plan", + content: [{ type: "text", text: "Rome Weekend Plan" }], + reasoning: [], + files: [], + sources: [], + toolCalls: [], + toolResults: [], + finishReason: "stop", + usage: providerUsage, + warnings: [ + { + type: "unsupported", + feature: "temperature", + details: "temperature is not supported for reasoning models", + }, + ], + request: {}, + response: { + id: "test-response", + modelId: "test-model", + timestamp: new Date(), + messages: createAssistantResponseMessages("Rome Weekend Plan"), + }, + steps: [], + } as any); + + const titleGenerator = (agent as any).createConversationTitleGenerator(memory); + const title = await titleGenerator({ + input: "Plan a weekend trip to Rome.", + context, + defaultTitle: "Conversation", + }); + + expect(title).toBe("Rome Weekend Plan"); + const generateTextCall = vi.mocked(ai.generateText).mock.calls[0][0] as Record< + string, + unknown + >; + expect(generateTextCall.temperature).toBe(0); + expect(generateTextCall.maxOutputTokens).toBe(32); + const createChildSpanCall = context.traceContext.createChildSpan.mock.calls[0][2] as { + attributes: Record; + }; + expect(createChildSpanCall.attributes["llm.temperature"]).toBe(0); + expect(context.logger.warn).toHaveBeenCalledWith( + "[Memory] Conversation title generation model does not support temperature", + expect.objectContaining({ + hint: expect.stringContaining("generateTitle.temperature"), + warning: expect.stringContaining("temperature"), + }), + ); + }); + + it("should omit temperature when title generation temperature is null", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + generateTitle: { enabled: true, temperature: null }, + }); + const agent = new Agent({ + name: "TestAgent", + instructions: "Test", + model: mockModel as any, + memory, + }); + const span = { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + recordException: vi.fn(), + }; + const context = { + operationId: "test-operation-id", + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + }, + context: new Map(), + systemContext: new Map(), + isActive: true, + traceContext: { + createChildSpan: vi.fn().mockReturnValue(span), + withSpan: vi.fn().mockImplementation(async (_span, fn) => await fn()), + }, + abortController: new AbortController(), + startTime: new Date(), + }; + + vi.mocked(ai.generateText).mockResolvedValue({ + text: "Rome Weekend Plan", + content: [{ type: "text", text: "Rome Weekend Plan" }], + reasoning: [], + files: [], + sources: [], + toolCalls: [], + toolResults: [], + finishReason: "stop", + usage: providerUsage, + warnings: [], + request: {}, + response: { + id: "test-response", + modelId: "test-model", + timestamp: new Date(), + messages: createAssistantResponseMessages("Rome Weekend Plan"), + }, + steps: [], + } as any); + + const titleGenerator = (agent as any).createConversationTitleGenerator(memory); + const title = await titleGenerator({ + input: "Plan a weekend trip to Rome.", + context, + defaultTitle: "Conversation", + }); + + expect(title).toBe("Rome Weekend Plan"); + const generateTextCall = vi.mocked(ai.generateText).mock.calls[0][0] as Record< + string, + unknown + >; + expect(generateTextCall).not.toHaveProperty("temperature"); + const createChildSpanCall = context.traceContext.createChildSpan.mock.calls[0][2] as { + attributes: Record; + }; + expect(createChildSpanCall.attributes).not.toHaveProperty("llm.temperature"); + }); + + it("should warn when conversation title generation returns an empty title", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + generateTitle: true, + }); + const agent = new Agent({ + name: "TestAgent", + instructions: "Test", + model: mockModel as any, + memory, + }); + const span = { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + recordException: vi.fn(), + }; + const context = { + operationId: "test-operation-id", + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + }, + context: new Map(), + systemContext: new Map(), + isActive: true, + traceContext: { + createChildSpan: vi.fn().mockReturnValue(span), + withSpan: vi.fn().mockImplementation(async (_span, fn) => await fn()), + }, + abortController: new AbortController(), + startTime: new Date(), + }; + + vi.mocked(ai.generateText).mockResolvedValue({ + text: " ", + content: [{ type: "text", text: " " }], + reasoning: [], + files: [], + sources: [], + toolCalls: [], + toolResults: [], + finishReason: "stop", + usage: providerUsage, + warnings: [], + request: {}, + response: { + id: "test-response", + modelId: "test-model", + timestamp: new Date(), + messages: createAssistantResponseMessages(" "), + }, + providerMetadata: { provider: { reason: "empty-output" } }, + steps: [], + } as any); + + const titleGenerator = (agent as any).createConversationTitleGenerator(memory); + const title = await titleGenerator({ + input: "Plan a weekend trip to Rome.", + context, + defaultTitle: "Conversation", + }); + + expect(title).toBeNull(); + expect(context.logger.warn).toHaveBeenCalledWith( + "[Memory] Conversation title generation returned an empty title", + expect.objectContaining({ + text: " ", + finishReason: "stop", + }), + ); + }); + + it("should keep full conversation title generation errors at debug level", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + generateTitle: true, + }); + const agent = new Agent({ + name: "TestAgent", + instructions: "Test", + model: mockModel as any, + memory, + }); + const span = { + end: vi.fn(), + setStatus: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), + recordException: vi.fn(), + }; + const context = { + operationId: "test-operation-id", + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn().mockReturnThis(), + }, + context: new Map(), + systemContext: new Map(), + isActive: true, + traceContext: { + createChildSpan: vi.fn().mockReturnValue(span), + withSpan: vi.fn().mockImplementation(async (_span, fn) => await fn()), + }, + abortController: new AbortController(), + startTime: new Date(), + }; + + vi.mocked(ai.generateText).mockRejectedValue(new Error("Unsupported temperature")); + + const titleGenerator = (agent as any).createConversationTitleGenerator(memory); + const title = await titleGenerator({ + input: "Plan a weekend trip to Rome.", + context, + defaultTitle: "Conversation", + }); + + expect(title).toBeNull(); + expect(context.logger.warn).toHaveBeenCalledWith( + "[Memory] Failed to generate conversation title", + expect.not.objectContaining({ + error: expect.anything(), + }), + ); + expect(context.logger.warn).toHaveBeenCalledWith( + "[Memory] Failed to generate conversation title", + expect.objectContaining({ + message: "Unsupported temperature", + hint: expect.stringContaining("generateTitle.temperature"), + }), + ); + expect(context.logger.debug).toHaveBeenCalledWith( + "[Memory] Full error for title generation", + expect.objectContaining({ + error: expect.any(String), + }), + ); + }); + it("should initialize with memory", () => { const memory = new Memory({ storage: new InMemoryStorageAdapter(), diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index c97e12c85..6c1a1a25b 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -258,6 +258,34 @@ const isRecord = (value: unknown): value is Record => const isPlainObject = (value: unknown): value is Record => isRecord(value) && !Array.isArray(value); +const stringIncludesTemperature = (value: unknown): boolean => + typeof value === "string" && value.toLowerCase().includes("temperature"); + +const isTemperatureWarning = (warning: Warning): boolean => { + const warningRecord: Record = warning; + const warningType = warningRecord.type; + + if (typeof warningType !== "string") { + return false; + } + + if (warningType === "unsupported-setting") { + return stringIncludesTemperature(warningRecord.setting); + } + + if (warningType === "unsupported" || warningType === "compatibility") { + return ( + stringIncludesTemperature(warningRecord.feature) || + stringIncludesTemperature(warningRecord.details) + ); + } + + return ( + stringIncludesTemperature(warningRecord.details) || + stringIncludesTemperature(warningRecord.message) + ); +}; + const hasNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; @@ -4721,6 +4749,12 @@ export class Agent { typeof normalized.maxLength === "number" && Number.isFinite(normalized.maxLength) ? Math.max(1, normalized.maxLength) : DEFAULT_CONVERSATION_TITLE_MAX_CHARS; + const temperature = + normalized.temperature === null + ? undefined + : typeof normalized.temperature === "number" && Number.isFinite(normalized.temperature) + ? normalized.temperature + : 0; const modelOverride = normalized.model; @@ -4751,7 +4785,7 @@ export class Agent { isStreaming: false, messages, callOptions: { - temperature: 0, + ...(temperature !== undefined ? { temperature } : {}), maxOutputTokens, }, label: "Generate Conversation Title", @@ -4764,16 +4798,32 @@ export class Agent { generateText({ model: resolvedModel, messages, - temperature: 0, + ...(temperature !== undefined ? { temperature } : {}), maxOutputTokens, abortSignal: context.abortController.signal, }), ); + const temperatureWarning = result.warnings?.find(isTemperatureWarning); + if (temperatureWarning) { + context.logger.warn( + "[Memory] Conversation title generation model does not support temperature", + { + warning: safeStringify(temperatureWarning), + hint: "Set generateTitle.temperature to null to omit temperature for title generation.", + }, + ); + } + const resolvedUsage = result.usage ? await Promise.resolve(result.usage) : undefined; const title = sanitizeConversationTitle(result.text ?? "", maxLength); if (title) { llmSpan.setAttribute("output", title); + } else { + context.logger.warn("[Memory] Conversation title generation returned an empty title", { + text: result.text ?? "", + finishReason: result.finishReason, + }); } finalizeLLMSpan(SpanStatusCode.OK, { usage: resolvedUsage, @@ -4787,7 +4837,11 @@ export class Agent { throw error; } } catch (error) { - context.logger.debug("[Memory] Failed to generate conversation title", { + context.logger.warn("[Memory] Failed to generate conversation title", { + message: error instanceof Error ? error.message : undefined, + hint: "If your title generation model does not support temperature, set generateTitle.temperature to null.", + }); + context.logger.debug("[Memory] Full error for title generation", { error: safeStringify(error), }); return null; diff --git a/packages/core/src/memory/manager/memory-manager.spec.ts b/packages/core/src/memory/manager/memory-manager.spec.ts index e9e5a8a31..92ec92394 100644 --- a/packages/core/src/memory/manager/memory-manager.spec.ts +++ b/packages/core/src/memory/manager/memory-manager.spec.ts @@ -128,6 +128,38 @@ describe("MemoryManager", () => { expect(titleGenerator).toHaveBeenCalledTimes(1); }); + it("should warn when title generation fails", async () => { + const context = createMockOperationContext(); + context.input = "Plan a weekend trip to Rome."; + const warnSpy = vi.spyOn(context.logger, "warn"); + const titleGenerator = vi.fn().mockRejectedValue(new Error("Unsupported temperature")); + 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-warning"); + + const conversation = await memory.getConversation("conv-title-warning"); + expect(conversation?.title).toBe("Conversation"); + expect(warnSpy).toHaveBeenCalledWith( + "[Memory] Failed to generate conversation title", + expect.objectContaining({ + message: "Unsupported temperature", + hint: expect.stringContaining("generateTitle.temperature"), + }), + ); + }); + 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 df23eeb6b..508d8a4e8 100644 --- a/packages/core/src/memory/manager/memory-manager.ts +++ b/packages/core/src/memory/manager/memory-manager.ts @@ -610,8 +610,10 @@ export class MemoryManager { return title.trim(); } } catch (error) { - context.logger.debug("[Memory] Failed to generate conversation title", { + context.logger.warn("[Memory] Failed to generate conversation title", { error: safeStringify(error), + message: error instanceof Error ? error.message : undefined, + hint: "If your title generation model does not support temperature, set generateTitle.temperature to null.", }); } diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts index d73d09bce..3f595cd07 100644 --- a/packages/core/src/memory/types.ts +++ b/packages/core/src/memory/types.ts @@ -104,6 +104,7 @@ export type MemoryOptions = {}; export type ConversationTitleConfig = { enabled?: boolean; model?: AgentModelValue; + temperature?: number | null; maxOutputTokens?: number; maxLength?: number; systemPrompt?: string | null; diff --git a/website/docs/agents/memory/overview.md b/website/docs/agents/memory/overview.md index d7687e712..032387ce6 100644 --- a/website/docs/agents/memory/overview.md +++ b/website/docs/agents/memory/overview.md @@ -49,8 +49,9 @@ const memory = new Memory({ enabled: true, model: "gpt-4o-mini", // default agent model systemPrompt: "Generate a short title (max 6 words).", + temperature: null, // optional; default is 0, use null to omit maxLength: 60, - maxOutputTokens: 24, + maxOutputTokens: 128, // optional; default is 32 }, }); ``` @@ -59,6 +60,12 @@ 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. +- Title generation sends `temperature: 0` by default. Set `generateTitle.temperature` to `null` to + omit the parameter for reasoning models such as OpenAI `o`-series and `gpt-5-mini`. +- Reasoning models may need a higher `maxOutputTokens` value because reasoning tokens count toward + the output budget. Configure `generateTitle.maxOutputTokens` if the default `32` is too low. +- If title generation fails, VoltAgent keeps the default conversation title and logs the cause at + `warn` level, including a hint to set `generateTitle.temperature` to `null` when appropriate. - Only the first user message is summarized. - If you create conversations manually via the Memory API, set `title` explicitly.