diff --git a/.changeset/funny-ravens-wave.md b/.changeset/funny-ravens-wave.md new file mode 100644 index 000000000..87dec910c --- /dev/null +++ b/.changeset/funny-ravens-wave.md @@ -0,0 +1,48 @@ +--- +"@voltagent/core": patch +"@voltagent/server-core": patch +--- + +feat: persist selected assistant message metadata to memory + +You can enable persisted assistant message metadata at the agent level or per request. + +```ts +const result = await agent.streamText("Hello", { + memory: { + userId: "user-1", + conversationId: "conv-1", + options: { + messageMetadataPersistence: { + usage: true, + finishReason: true, + }, + }, + }, +}); +``` + +With this enabled, fetching messages from memory returns assistant `UIMessage.metadata` +with fields like `usage` and `finishReason`, not just stream-time metadata. + +REST API requests can enable the same behavior with `options.memory.options`: + +```bash +curl -X POST http://localhost:3141/agents/assistant/text \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello", + "options": { + "memory": { + "userId": "user-1", + "conversationId": "conv-1", + "options": { + "messageMetadataPersistence": { + "usage": true, + "finishReason": true + } + } + } + } + }' +``` diff --git a/packages/core/src/agent/agent.spec.ts b/packages/core/src/agent/agent.spec.ts index 54df23d7a..9217669c5 100644 --- a/packages/core/src/agent/agent.spec.ts +++ b/packages/core/src/agent/agent.spec.ts @@ -1877,6 +1877,36 @@ Use pandas and summarize findings.`.split("\n"), }); describe("Memory Integration", () => { + const persistedUsage = { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + cachedInputTokens: 0, + reasoningTokens: 0, + }; + + const providerUsage = { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }; + + const createAssistantResponseMessages = (text: string): ModelMessage[] => [ + { + role: "assistant", + content: [{ type: "text", text }], + }, + ]; + + const getLastAssistantMessage = async ( + memory: Memory, + userId: string, + conversationId: string, + ) => { + const messages = await memory.getMessages(userId, conversationId); + return [...messages].reverse().find((message) => message.role === "assistant"); + }; + it("should initialize with memory", () => { const memory = new Memory({ storage: new InMemoryStorageAdapter(), @@ -1957,6 +1987,166 @@ Use pandas and summarize findings.`.split("\n"), // as they're handled by the MemoryManager class }); + it("should persist usage and finish reason in assistant message metadata for generateText", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + }); + + const agent = new Agent({ + name: "TestAgent", + instructions: "Test", + model: mockModel as any, + memory, + }); + + vi.mocked(ai.generateText).mockResolvedValue({ + text: "Persisted response", + content: [{ type: "text", text: "Persisted response" }], + reasoning: [], + files: [], + sources: [], + toolCalls: [], + toolResults: [], + finishReason: "stop", + usage: providerUsage, + warnings: [], + request: {}, + response: { + id: "test-response", + modelId: "test-model", + timestamp: new Date(), + messages: createAssistantResponseMessages("Persisted response"), + }, + steps: [], + } as any); + + await agent.generateText("Hello", { + memory: { + userId: "user-metadata", + conversationId: "conv-metadata", + options: { + messageMetadataPersistence: true, + }, + }, + }); + + const assistantMessage = await getLastAssistantMessage( + memory, + "user-metadata", + "conv-metadata", + ); + + expect(assistantMessage).toBeDefined(); + expect(assistantMessage?.metadata).toEqual( + expect.objectContaining({ + operationId: expect.any(String), + usage: persistedUsage, + finishReason: "stop", + }), + ); + }); + + it("should persist usage and finish reason in assistant message metadata for streamText", async () => { + const memory = new Memory({ + storage: new InMemoryStorageAdapter(), + }); + + const agent = new Agent({ + name: "TestAgent", + instructions: "Test", + model: mockModel as any, + memory, + }); + + vi.mocked(ai.streamText).mockImplementation((args: any) => { + const finalResult = { + text: "Persisted stream response", + finishReason: "stop", + usage: providerUsage, + totalUsage: providerUsage, + warnings: [], + response: { + id: "stream-response", + modelId: "test-model", + timestamp: new Date(), + messages: createAssistantResponseMessages("Persisted stream response"), + }, + steps: [], + providerMetadata: undefined, + }; + + const fullStream = (async function* () { + try { + yield { + type: "start" as const, + }; + yield { + type: "text-delta" as const, + id: "text-1", + delta: "Persisted stream response", + text: "Persisted stream response", + }; + yield { + type: "finish" as const, + finishReason: "stop", + totalUsage: providerUsage, + }; + } finally { + await args.onFinish?.(finalResult); + } + })(); + + return { + text: Promise.resolve("Persisted stream response"), + textStream: (async function* () { + yield "Persisted stream response"; + })(), + fullStream, + usage: Promise.resolve(providerUsage), + finishReason: Promise.resolve("stop"), + warnings: [], + toUIMessageStream: vi.fn(), + toUIMessageStreamResponse: vi.fn(), + pipeUIMessageStreamToResponse: vi.fn(), + pipeTextStreamToResponse: vi.fn(), + toTextStreamResponse: vi.fn(), + partialOutputStream: undefined, + } as any; + }); + + const result = await agent.streamText("Hello", { + memory: { + userId: "user-stream-metadata", + conversationId: "conv-stream-metadata", + options: { + messageMetadataPersistence: { + usage: true, + finishReason: true, + }, + }, + }, + }); + + for await (const _part of result.fullStream) { + // Consume stream to trigger mocked onFinish. + } + + const assistantMessage = await getLastAssistantMessage( + memory, + "user-stream-metadata", + "conv-stream-metadata", + ); + + expect(assistantMessage).toBeDefined(); + expect(assistantMessage?.metadata).toEqual( + expect.objectContaining({ + operationId: expect.any(String), + usage: persistedUsage, + finishReason: "stop", + }), + ); + }); + it("should read memory but skip persistence when memory.options.readOnly is true", async () => { const memory = new Memory({ storage: new InMemoryStorageAdapter(), @@ -2248,6 +2438,7 @@ Use pandas and summarize findings.`.split("\n"), conversationPersistence: { mode: "finish", }, + messageMetadataPersistence: false, memory: { userId: "memory-user", conversationId: "memory-conv", @@ -2262,6 +2453,9 @@ Use pandas and summarize findings.`.split("\n"), mode: "step", debounceMs: 120, }, + messageMetadataPersistence: { + usage: true, + }, }, }, }); @@ -2281,6 +2475,10 @@ Use pandas and summarize findings.`.split("\n"), mode: "step", debounceMs: 120, }, + messageMetadataPersistence: { + usage: true, + finishReason: false, + }, }); }); @@ -2305,6 +2503,9 @@ Use pandas and summarize findings.`.split("\n"), conversationPersistence: { mode: "finish", }, + messageMetadataPersistence: { + finishReason: true, + }, }, }, }); @@ -2325,6 +2526,10 @@ Use pandas and summarize findings.`.split("\n"), conversationPersistence: { mode: "finish", }, + messageMetadataPersistence: { + usage: false, + finishReason: true, + }, }); }); }); diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 511916d17..1d0280c0e 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -187,6 +187,8 @@ import type { AgentFullState, AgentGuardrailState, AgentMarkFeedbackProvidedInput, + AgentMessageMetadataPersistenceConfig, + AgentMessageMetadataPersistenceOptions, AgentModelConfig, AgentModelValue, AgentOptions, @@ -238,6 +240,16 @@ const DEFAULT_CONVERSATION_PERSISTENCE_OPTIONS: ResolvedConversationPersistenceO flushOnToolResult: true, }; +type ResolvedMessageMetadataPersistenceOptions = { + usage: boolean; + finishReason: boolean; +}; + +const DEFAULT_MESSAGE_METADATA_PERSISTENCE_OPTIONS: ResolvedMessageMetadataPersistenceOptions = { + usage: false, + finishReason: false, +}; + type ResponseMessage = AssistantModelMessage | ToolModelMessage; const isRecord = (value: unknown): value is Record => @@ -856,6 +868,10 @@ export interface BaseGenerationOptions( result.text, oc, @@ -1841,6 +1873,7 @@ export class Agent { contextLimit: _contextLimit, semanticMemory: _semanticMemory, conversationPersistence: _conversationPersistence, + messageMetadataPersistence: _messageMetadataPersistence, output, providerOptions, ...aiSDKOptions @@ -2032,6 +2065,18 @@ export class Agent { providerMetadata: finalResult.providerMetadata, }); + const usage = convertUsage(usageForFinish); + const persistedAssistantMetadata = this.buildPersistedAssistantMessageMetadata({ + oc, + usage, + finishReason: finalResult.finishReason ?? null, + }); + this.applyMetadataToLastAssistantMessage({ + buffer, + metadata: persistedAssistantMetadata, + responseMessages: latestResponseMessages, + }); + if (!shouldDeferPersist && shouldPersistMemory) { await persistQueue.flush(buffer, oc); } @@ -2039,8 +2084,6 @@ export class Agent { // History update removed - using OpenTelemetry only // Event tracking now handled by OpenTelemetry spans - - const usage = convertUsage(usageForFinish); let finalText: string; // Check if we aborted due to subagent bail (early termination) @@ -2727,6 +2770,7 @@ export class Agent { contextLimit: _contextLimit, semanticMemory: _semanticMemory, conversationPersistence: _conversationPersistence, + messageMetadataPersistence: _messageMetadataPersistence, output: _output, providerOptions, ...aiSDKOptions @@ -2804,16 +2848,23 @@ export class Agent { // Save the object response to memory if (this.shouldPersistMemoryForContext(oc) && oc.userId && oc.conversationId) { // Create UIMessage from the object response - const message: UIMessage = { - id: randomUUID(), - role: "assistant", - parts: [ - { - type: "text", - text: safeStringify(finalObject), - }, - ], - }; + const message: UIMessage = this.applyMetadataToMessage( + { + id: randomUUID(), + role: "assistant", + parts: [ + { + type: "text", + text: safeStringify(finalObject), + }, + ], + }, + this.buildPersistedAssistantMessageMetadata({ + oc, + usage: usageInfo, + finishReason: result.finishReason ?? null, + }), + ); // Save the message to memory await this.memoryManager.saveMessage(oc, message, oc.userId, oc.conversationId); @@ -3093,6 +3144,7 @@ export class Agent { contextLimit: _contextLimit, semanticMemory: _semanticMemory, conversationPersistence: _conversationPersistence, + messageMetadataPersistence: _messageMetadataPersistence, output: _output, providerOptions, ...aiSDKOptions @@ -3248,16 +3300,23 @@ export class Agent { } if (this.shouldPersistMemoryForContext(oc) && oc.userId && oc.conversationId) { - const message: UIMessage = { - id: randomUUID(), - role: "assistant", - parts: [ - { - type: "text", - text: safeStringify(finalObject), - }, - ], - }; + const message: UIMessage = this.applyMetadataToMessage( + { + id: randomUUID(), + role: "assistant", + parts: [ + { + type: "text", + text: safeStringify(finalObject), + }, + ], + }, + this.buildPersistedAssistantMessageMetadata({ + oc, + usage: usageInfo, + finishReason: finalResult.finishReason ?? null, + }), + ); await this.memoryManager.saveMessage(oc, message, oc.userId, oc.conversationId); @@ -3679,6 +3738,30 @@ export class Agent { }; } + private normalizeMessageMetadataPersistenceOptions( + options?: AgentMessageMetadataPersistenceConfig | AgentMessageMetadataPersistenceOptions, + defaults: ResolvedMessageMetadataPersistenceOptions = DEFAULT_MESSAGE_METADATA_PERSISTENCE_OPTIONS, + ): ResolvedMessageMetadataPersistenceOptions { + if (options === true) { + return { + usage: true, + finishReason: true, + }; + } + + if (options === false) { + return { + usage: false, + finishReason: false, + }; + } + + return { + usage: options?.usage ?? defaults.usage, + finishReason: options?.finishReason ?? defaults.finishReason, + }; + } + private resolveConversationPersistenceOptions( options?: BaseGenerationOptions, ): ResolvedConversationPersistenceOptions { @@ -3738,6 +3821,14 @@ export class Agent { memoryOptions?.conversationPersistence ?? options?.conversationPersistence ?? parentResolvedMemory?.conversationPersistence, + messageMetadataPersistence: + contextResolvedMemory?.messageMetadataPersistence ?? + this.normalizeMessageMetadataPersistenceOptions( + memoryOptions?.messageMetadataPersistence ?? + options?.messageMetadataPersistence ?? + parentResolvedMemory?.messageMetadataPersistence, + this.messageMetadataPersistence, + ), readOnly: firstDefined( contextResolvedMemory?.readOnly, memoryOptions?.readOnly, @@ -3762,6 +3853,78 @@ export class Agent { return resolved; } + private getMessageMetadataPersistenceOptionsForContext( + oc: OperationContext, + ): ResolvedMessageMetadataPersistenceOptions { + return this.normalizeMessageMetadataPersistenceOptions( + oc.resolvedMemory?.messageMetadataPersistence, + this.messageMetadataPersistence, + ); + } + + private buildPersistedAssistantMessageMetadata(params: { + oc: OperationContext; + usage?: UsageInfo; + finishReason?: string | null; + }): Record | undefined { + const persistence = this.getMessageMetadataPersistenceOptionsForContext(params.oc); + const metadata: Record = {}; + + if (persistence.usage && params.usage) { + metadata.usage = params.usage; + } + + if (persistence.finishReason) { + metadata.finishReason = params.finishReason ?? null; + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; + } + + private applyMetadataToLastAssistantMessage(params: { + buffer: ConversationBuffer; + metadata?: Record; + responseMessages?: ModelMessage[]; + }): boolean { + const { buffer, metadata, responseMessages } = params; + if (!metadata || Object.keys(metadata).length === 0) { + return false; + } + + const metadataApplied = buffer.addMetadataToLastAssistantMessage(metadata, { + requirePending: true, + }); + if (metadataApplied) { + return true; + } + + if (responseMessages?.length) { + buffer.addModelMessages(responseMessages, "response"); + return buffer.addMetadataToLastAssistantMessage(metadata, { + requirePending: true, + }); + } + + return false; + } + + private applyMetadataToMessage( + message: UIMessage, + metadata?: Record, + ): UIMessage { + if (!metadata || Object.keys(metadata).length === 0) { + return message; + } + + return { + ...message, + metadata: { + ...((message.metadata as Record | undefined) ?? {}), + ...metadata, + }, + }; + } + /** * Create only the OperationContext (sync) * Transitional helper to gradually adopt OperationContext across methods @@ -8208,6 +8371,9 @@ export class Agent { ...(resolvedMemory.conversationPersistence !== undefined ? { conversationPersistence: resolvedMemory.conversationPersistence } : {}), + ...(resolvedMemory.messageMetadataPersistence !== undefined + ? { messageMetadataPersistence: resolvedMemory.messageMetadataPersistence } + : {}), ...(resolvedMemory.readOnly !== undefined ? { readOnly: resolvedMemory.readOnly } : {}), diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index b166f1ec1..a4e6eb82c 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -635,6 +635,21 @@ export type AgentConversationPersistenceOptions = { flushOnToolResult?: boolean; }; +export type AgentMessageMetadataPersistenceOptions = { + /** + * Persist resolved usage info under `message.metadata.usage`. + */ + usage?: boolean; + /** + * Persist the final finish reason under `message.metadata.finishReason`. + */ + finishReason?: boolean; +}; + +export type AgentMessageMetadataPersistenceConfig = + | boolean + | AgentMessageMetadataPersistenceOptions; + /** * Agent configuration options */ @@ -667,6 +682,7 @@ export type AgentOptions = { memory?: Memory | false; summarization?: AgentSummarizationOptions | false; conversationPersistence?: AgentConversationPersistenceOptions; + messageMetadataPersistence?: AgentMessageMetadataPersistenceConfig; // Retriever/RAG retriever?: BaseRetriever; @@ -954,6 +970,7 @@ export interface CommonRuntimeMemoryBehaviorOptions { contextLimit?: number; semanticMemory?: CommonSemanticMemoryOptions; conversationPersistence?: AgentConversationPersistenceOptions; + messageMetadataPersistence?: AgentMessageMetadataPersistenceConfig; /** * When true, memory is read-only for the current call. * Existing memory context can be loaded, but no writes are persisted. @@ -977,6 +994,7 @@ export interface CommonResolvedRuntimeMemoryOptions { contextLimit?: number; semanticMemory?: CommonSemanticMemoryOptions; conversationPersistence?: AgentConversationPersistenceOptions; + messageMetadataPersistence?: AgentMessageMetadataPersistenceOptions; readOnly?: boolean; } @@ -1011,6 +1029,11 @@ export interface CommonGenerateOptions { // Semantic memory runtime overrides semanticMemory?: CommonSemanticMemoryOptions; + /** + * @deprecated Use `memory.options.messageMetadataPersistence` instead. + */ + messageMetadataPersistence?: AgentMessageMetadataPersistenceConfig; + /** * @deprecated Use `memory.options.conversationPersistence` instead. */ diff --git a/packages/server-core/src/schemas/agent.schemas.spec.ts b/packages/server-core/src/schemas/agent.schemas.spec.ts index 11b91c7e7..75cb06dbf 100644 --- a/packages/server-core/src/schemas/agent.schemas.spec.ts +++ b/packages/server-core/src/schemas/agent.schemas.spec.ts @@ -56,6 +56,10 @@ describe("GenerateOptionsSchema", () => { options: { contextLimit: 12, readOnly: true, + messageMetadataPersistence: { + usage: true, + finishReason: true, + }, semanticMemory: { enabled: true, semanticLimit: 4, @@ -77,6 +81,10 @@ describe("GenerateOptionsSchema", () => { expect(result.memory?.conversationId).toBe("conv-1"); expect(result.memory?.options?.contextLimit).toBe(12); expect(result.memory?.options?.readOnly).toBe(true); + expect(result.memory?.options?.messageMetadataPersistence).toEqual({ + usage: true, + finishReason: true, + }); expect(result.memory?.options?.semanticMemory?.enabled).toBe(true); expect(result.memory?.options?.semanticMemory?.semanticLimit).toBe(4); expect(result.memory?.options?.semanticMemory?.semanticThreshold).toBe(0.8); @@ -97,6 +105,7 @@ describe("GenerateOptionsSchema", () => { conversationPersistence: { mode: "finish", }, + messageMetadataPersistence: true, }; expect(() => GenerateOptionsSchema.parse(payload)).not.toThrow(); diff --git a/packages/server-core/src/schemas/agent.schemas.ts b/packages/server-core/src/schemas/agent.schemas.ts index 87316fca8..713d4e957 100644 --- a/packages/server-core/src/schemas/agent.schemas.ts +++ b/packages/server-core/src/schemas/agent.schemas.ts @@ -210,6 +210,23 @@ const ConversationPersistenceOptionsSchema = z }) .passthrough(); +const MessageMetadataPersistenceOptionsSchema = z + .object({ + usage: z + .boolean() + .optional() + .describe("Persist resolved token usage under message.metadata.usage"), + finishReason: z + .boolean() + .optional() + .describe("Persist the final finish reason under message.metadata.finishReason"), + }) + .passthrough(); + +const MessageMetadataPersistenceConfigSchema = z + .union([z.boolean(), MessageMetadataPersistenceOptionsSchema]) + .describe("Controls which assistant message metadata fields are persisted to memory"); + const RuntimeMemoryBehaviorOptionsSchema = z .object({ contextLimit: z @@ -228,6 +245,9 @@ const RuntimeMemoryBehaviorOptionsSchema = z conversationPersistence: ConversationPersistenceOptionsSchema.optional().describe( "Per-call conversation persistence behavior", ), + messageMetadataPersistence: MessageMetadataPersistenceConfigSchema.optional().describe( + "Per-call persisted assistant message metadata behavior", + ), }) .passthrough(); @@ -266,6 +286,9 @@ export const GenerateOptionsSchema = z conversationPersistence: ConversationPersistenceOptionsSchema.optional().describe( "Deprecated: use options.memory.options.conversationPersistence", ), + messageMetadataPersistence: MessageMetadataPersistenceConfigSchema.optional().describe( + "Deprecated: use options.memory.options.messageMetadataPersistence", + ), maxSteps: z .number() .int() diff --git a/website/docs/api/endpoints/agents.md b/website/docs/api/endpoints/agents.md index c9e11d66e..1034dbdd2 100644 --- a/website/docs/api/endpoints/agents.md +++ b/website/docs/api/endpoints/agents.md @@ -258,6 +258,37 @@ curl -X POST http://localhost:3141/agents/assistant/text \ }' ``` +**Persisting Assistant Metadata to Memory:** + +If you want `usage` and `finishReason` to be visible later in memory-backed +`UIMessage.metadata`, enable `memory.options.messageMetadataPersistence`: + +```bash +curl -X POST http://localhost:3141/agents/assistant/text \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello", + "options": { + "memory": { + "userId": "user-1", + "conversationId": "conv-1", + "options": { + "messageMetadataPersistence": { + "usage": true, + "finishReason": true + } + } + } + } + }' +``` + +Then retrieve the saved messages: + +```bash +curl "http://localhost:3141/api/memory/conversations/conv-1/messages?agentId=assistant&userId=user-1" +``` + ### Using output for Structured Generation The `/text`, `/stream`, and `/chat` endpoints support structured output generation using the `output` option. Unlike the `/object` endpoint, this allows you to get structured data **while maintaining full tool calling capabilities**. diff --git a/website/docs/ui/ai-sdk-integration.md b/website/docs/ui/ai-sdk-integration.md index ff6e4881a..98f54af56 100644 --- a/website/docs/ui/ai-sdk-integration.md +++ b/website/docs/ui/ai-sdk-integration.md @@ -350,24 +350,28 @@ export function ChatInterface() { ### VoltAgent Specific -| Option | Type | Description | -| ---------------------------------------------------------- | ------- | ------------------------------------------------------------------------------ | -| `memory` | object | Runtime memory envelope (preferred) | -| `memory.userId` | string | User identifier for memory persistence | -| `memory.conversationId` | string | Conversation thread ID | -| `context` | object | Dynamic context (converted to Map internally) | -| `memory.options.contextLimit` | number | Number of previous messages to include from memory | -| `memory.options.readOnly` | boolean | Read memory context but skip all memory writes for this call | -| `memory.options.conversationPersistence.mode` | string | `"step"` (default) or `"finish"` | -| `memory.options.conversationPersistence.debounceMs` | number | Debounce window in milliseconds (default: `200`) | -| `memory.options.conversationPersistence.flushOnToolResult` | boolean | Flush immediately on `tool-result`/`tool-error` in step mode (default: `true`) | -| `userId` | string | Deprecated: use `memory.userId` | -| `conversationId` | string | Deprecated: use `memory.conversationId` | -| `contextLimit` | number | Deprecated: use `memory.options.contextLimit` | -| `semanticMemory` | object | Deprecated: use `memory.options.semanticMemory` | -| `conversationPersistence.mode` | string | Deprecated: use `memory.options.conversationPersistence.mode` | -| `conversationPersistence.debounceMs` | number | Deprecated: use `memory.options.conversationPersistence.debounceMs` | -| `conversationPersistence.flushOnToolResult` | boolean | Deprecated: use `memory.options.conversationPersistence.flushOnToolResult` | +| Option | Type | Description | +| ---------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------ | +| `memory` | object | Runtime memory envelope (preferred) | +| `memory.userId` | string | User identifier for memory persistence | +| `memory.conversationId` | string | Conversation thread ID | +| `context` | object | Dynamic context (converted to Map internally) | +| `memory.options.contextLimit` | number | Number of previous messages to include from memory | +| `memory.options.readOnly` | boolean | Read memory context but skip all memory writes for this call | +| `memory.options.conversationPersistence.mode` | string | `"step"` (default) or `"finish"` | +| `memory.options.conversationPersistence.debounceMs` | number | Debounce window in milliseconds (default: `200`) | +| `memory.options.conversationPersistence.flushOnToolResult` | boolean | Flush immediately on `tool-result`/`tool-error` in step mode (default: `true`) | +| `memory.options.messageMetadataPersistence` | boolean \| object | Persist assistant `message.metadata` fields such as usage and finish reason | +| `memory.options.messageMetadataPersistence.usage` | boolean | Persist token usage under `message.metadata.usage` | +| `memory.options.messageMetadataPersistence.finishReason` | boolean | Persist the model finish reason under `message.metadata.finishReason` | +| `userId` | string | Deprecated: use `memory.userId` | +| `conversationId` | string | Deprecated: use `memory.conversationId` | +| `contextLimit` | number | Deprecated: use `memory.options.contextLimit` | +| `semanticMemory` | object | Deprecated: use `memory.options.semanticMemory` | +| `conversationPersistence.mode` | string | Deprecated: use `memory.options.conversationPersistence.mode` | +| `conversationPersistence.debounceMs` | number | Deprecated: use `memory.options.conversationPersistence.debounceMs` | +| `conversationPersistence.flushOnToolResult` | boolean | Deprecated: use `memory.options.conversationPersistence.flushOnToolResult` | +| `messageMetadataPersistence` | boolean \| object | Deprecated: use `memory.options.messageMetadataPersistence` | Example: @@ -378,6 +382,10 @@ options: { conversationId, options: { readOnly: false, + messageMetadataPersistence: { + usage: true, + finishReason: true, + }, conversationPersistence: { mode: "step", debounceMs: 200, @@ -392,6 +400,36 @@ When both top-level legacy memory fields and `memory` envelope fields are provid Set `memory.options.readOnly: true` to load memory context without persisting new messages for that request. +Set `memory.options.messageMetadataPersistence` to persist selected assistant metadata into memory-backed `UIMessage.metadata`. For example, enabling `usage` and `finishReason` makes those fields visible when you later fetch messages from memory, not just in the live UI stream. + +REST API example: + +```bash +curl -X POST http://localhost:3141/agents/assistant/text \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello", + "options": { + "memory": { + "userId": "user-1", + "conversationId": "conv-1", + "options": { + "messageMetadataPersistence": { + "usage": true, + "finishReason": true + } + } + } + } + }' +``` + +Then fetch the saved messages: + +```bash +curl "http://localhost:3141/api/memory/conversations/conv-1/messages?agentId=assistant&userId=user-1" +``` + ### AI SDK Core Options | Option | Type | Default | Description |