Skip to content

Commit b48f107

Browse files
authored
feat(core): persist assistant message metadata in memory (#1183)
* feat(core): persist assistant message metadata in memory * docs(changeset): add usage example for metadata persistence * docs(api): add REST examples for message metadata persistence * docs(changeset): add REST example for metadata persistence
1 parent 90b655d commit b48f107

8 files changed

Lines changed: 583 additions & 40 deletions

File tree

.changeset/funny-ravens-wave.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@voltagent/core": patch
3+
"@voltagent/server-core": patch
4+
---
5+
6+
feat: persist selected assistant message metadata to memory
7+
8+
You can enable persisted assistant message metadata at the agent level or per request.
9+
10+
```ts
11+
const result = await agent.streamText("Hello", {
12+
memory: {
13+
userId: "user-1",
14+
conversationId: "conv-1",
15+
options: {
16+
messageMetadataPersistence: {
17+
usage: true,
18+
finishReason: true,
19+
},
20+
},
21+
},
22+
});
23+
```
24+
25+
With this enabled, fetching messages from memory returns assistant `UIMessage.metadata`
26+
with fields like `usage` and `finishReason`, not just stream-time metadata.
27+
28+
REST API requests can enable the same behavior with `options.memory.options`:
29+
30+
```bash
31+
curl -X POST http://localhost:3141/agents/assistant/text \
32+
-H "Content-Type: application/json" \
33+
-d '{
34+
"input": "Hello",
35+
"options": {
36+
"memory": {
37+
"userId": "user-1",
38+
"conversationId": "conv-1",
39+
"options": {
40+
"messageMetadataPersistence": {
41+
"usage": true,
42+
"finishReason": true
43+
}
44+
}
45+
}
46+
}
47+
}'
48+
```

packages/core/src/agent/agent.spec.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,6 +1877,36 @@ Use pandas and summarize findings.`.split("\n"),
18771877
});
18781878

18791879
describe("Memory Integration", () => {
1880+
const persistedUsage = {
1881+
promptTokens: 10,
1882+
completionTokens: 5,
1883+
totalTokens: 15,
1884+
cachedInputTokens: 0,
1885+
reasoningTokens: 0,
1886+
};
1887+
1888+
const providerUsage = {
1889+
inputTokens: 10,
1890+
outputTokens: 5,
1891+
totalTokens: 15,
1892+
};
1893+
1894+
const createAssistantResponseMessages = (text: string): ModelMessage[] => [
1895+
{
1896+
role: "assistant",
1897+
content: [{ type: "text", text }],
1898+
},
1899+
];
1900+
1901+
const getLastAssistantMessage = async (
1902+
memory: Memory,
1903+
userId: string,
1904+
conversationId: string,
1905+
) => {
1906+
const messages = await memory.getMessages(userId, conversationId);
1907+
return [...messages].reverse().find((message) => message.role === "assistant");
1908+
};
1909+
18801910
it("should initialize with memory", () => {
18811911
const memory = new Memory({
18821912
storage: new InMemoryStorageAdapter(),
@@ -1957,6 +1987,166 @@ Use pandas and summarize findings.`.split("\n"),
19571987
// as they're handled by the MemoryManager class
19581988
});
19591989

1990+
it("should persist usage and finish reason in assistant message metadata for generateText", async () => {
1991+
const memory = new Memory({
1992+
storage: new InMemoryStorageAdapter(),
1993+
});
1994+
1995+
const agent = new Agent({
1996+
name: "TestAgent",
1997+
instructions: "Test",
1998+
model: mockModel as any,
1999+
memory,
2000+
});
2001+
2002+
vi.mocked(ai.generateText).mockResolvedValue({
2003+
text: "Persisted response",
2004+
content: [{ type: "text", text: "Persisted response" }],
2005+
reasoning: [],
2006+
files: [],
2007+
sources: [],
2008+
toolCalls: [],
2009+
toolResults: [],
2010+
finishReason: "stop",
2011+
usage: providerUsage,
2012+
warnings: [],
2013+
request: {},
2014+
response: {
2015+
id: "test-response",
2016+
modelId: "test-model",
2017+
timestamp: new Date(),
2018+
messages: createAssistantResponseMessages("Persisted response"),
2019+
},
2020+
steps: [],
2021+
} as any);
2022+
2023+
await agent.generateText("Hello", {
2024+
memory: {
2025+
userId: "user-metadata",
2026+
conversationId: "conv-metadata",
2027+
options: {
2028+
messageMetadataPersistence: true,
2029+
},
2030+
},
2031+
});
2032+
2033+
const assistantMessage = await getLastAssistantMessage(
2034+
memory,
2035+
"user-metadata",
2036+
"conv-metadata",
2037+
);
2038+
2039+
expect(assistantMessage).toBeDefined();
2040+
expect(assistantMessage?.metadata).toEqual(
2041+
expect.objectContaining({
2042+
operationId: expect.any(String),
2043+
usage: persistedUsage,
2044+
finishReason: "stop",
2045+
}),
2046+
);
2047+
});
2048+
2049+
it("should persist usage and finish reason in assistant message metadata for streamText", async () => {
2050+
const memory = new Memory({
2051+
storage: new InMemoryStorageAdapter(),
2052+
});
2053+
2054+
const agent = new Agent({
2055+
name: "TestAgent",
2056+
instructions: "Test",
2057+
model: mockModel as any,
2058+
memory,
2059+
});
2060+
2061+
vi.mocked(ai.streamText).mockImplementation((args: any) => {
2062+
const finalResult = {
2063+
text: "Persisted stream response",
2064+
finishReason: "stop",
2065+
usage: providerUsage,
2066+
totalUsage: providerUsage,
2067+
warnings: [],
2068+
response: {
2069+
id: "stream-response",
2070+
modelId: "test-model",
2071+
timestamp: new Date(),
2072+
messages: createAssistantResponseMessages("Persisted stream response"),
2073+
},
2074+
steps: [],
2075+
providerMetadata: undefined,
2076+
};
2077+
2078+
const fullStream = (async function* () {
2079+
try {
2080+
yield {
2081+
type: "start" as const,
2082+
};
2083+
yield {
2084+
type: "text-delta" as const,
2085+
id: "text-1",
2086+
delta: "Persisted stream response",
2087+
text: "Persisted stream response",
2088+
};
2089+
yield {
2090+
type: "finish" as const,
2091+
finishReason: "stop",
2092+
totalUsage: providerUsage,
2093+
};
2094+
} finally {
2095+
await args.onFinish?.(finalResult);
2096+
}
2097+
})();
2098+
2099+
return {
2100+
text: Promise.resolve("Persisted stream response"),
2101+
textStream: (async function* () {
2102+
yield "Persisted stream response";
2103+
})(),
2104+
fullStream,
2105+
usage: Promise.resolve(providerUsage),
2106+
finishReason: Promise.resolve("stop"),
2107+
warnings: [],
2108+
toUIMessageStream: vi.fn(),
2109+
toUIMessageStreamResponse: vi.fn(),
2110+
pipeUIMessageStreamToResponse: vi.fn(),
2111+
pipeTextStreamToResponse: vi.fn(),
2112+
toTextStreamResponse: vi.fn(),
2113+
partialOutputStream: undefined,
2114+
} as any;
2115+
});
2116+
2117+
const result = await agent.streamText("Hello", {
2118+
memory: {
2119+
userId: "user-stream-metadata",
2120+
conversationId: "conv-stream-metadata",
2121+
options: {
2122+
messageMetadataPersistence: {
2123+
usage: true,
2124+
finishReason: true,
2125+
},
2126+
},
2127+
},
2128+
});
2129+
2130+
for await (const _part of result.fullStream) {
2131+
// Consume stream to trigger mocked onFinish.
2132+
}
2133+
2134+
const assistantMessage = await getLastAssistantMessage(
2135+
memory,
2136+
"user-stream-metadata",
2137+
"conv-stream-metadata",
2138+
);
2139+
2140+
expect(assistantMessage).toBeDefined();
2141+
expect(assistantMessage?.metadata).toEqual(
2142+
expect.objectContaining({
2143+
operationId: expect.any(String),
2144+
usage: persistedUsage,
2145+
finishReason: "stop",
2146+
}),
2147+
);
2148+
});
2149+
19602150
it("should read memory but skip persistence when memory.options.readOnly is true", async () => {
19612151
const memory = new Memory({
19622152
storage: new InMemoryStorageAdapter(),
@@ -2248,6 +2438,7 @@ Use pandas and summarize findings.`.split("\n"),
22482438
conversationPersistence: {
22492439
mode: "finish",
22502440
},
2441+
messageMetadataPersistence: false,
22512442
memory: {
22522443
userId: "memory-user",
22532444
conversationId: "memory-conv",
@@ -2262,6 +2453,9 @@ Use pandas and summarize findings.`.split("\n"),
22622453
mode: "step",
22632454
debounceMs: 120,
22642455
},
2456+
messageMetadataPersistence: {
2457+
usage: true,
2458+
},
22652459
},
22662460
},
22672461
});
@@ -2281,6 +2475,10 @@ Use pandas and summarize findings.`.split("\n"),
22812475
mode: "step",
22822476
debounceMs: 120,
22832477
},
2478+
messageMetadataPersistence: {
2479+
usage: true,
2480+
finishReason: false,
2481+
},
22842482
});
22852483
});
22862484

@@ -2305,6 +2503,9 @@ Use pandas and summarize findings.`.split("\n"),
23052503
conversationPersistence: {
23062504
mode: "finish",
23072505
},
2506+
messageMetadataPersistence: {
2507+
finishReason: true,
2508+
},
23082509
},
23092510
},
23102511
});
@@ -2325,6 +2526,10 @@ Use pandas and summarize findings.`.split("\n"),
23252526
conversationPersistence: {
23262527
mode: "finish",
23272528
},
2529+
messageMetadataPersistence: {
2530+
usage: false,
2531+
finishReason: true,
2532+
},
23282533
});
23292534
});
23302535
});

0 commit comments

Comments
 (0)