mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 08:20:14 +01:00
🤖 refactor: Improve Agent Handoff Context Tracking (#10553)
* chore: update @librechat/agents dependency to version 3.0.18 * refactor: add optional metadata field to message schema and types * chore: update @librechat/agents to v3.0.19 * refactor: update return type of sendCompletion method to include metadata * chore: linting * chore: update @librechat/agents dependency to v3.0.20 * refactor: implement agent labeling for conversation history in multi-agent scenarios * refactor: improve error handling for capturing agent ID map in AgentClient * refactor: clear agentIdMap and related properties during client disposal to prevent memory leaks * chore: update sendCompletion method for FakeClient to return an object with completion and metadata fields
This commit is contained in:
parent
bdc47dbe47
commit
c0cb48256e
12 changed files with 122 additions and 22 deletions
|
|
@ -81,6 +81,7 @@ class BaseClient {
|
||||||
throw new Error("Method 'getCompletion' must be implemented.");
|
throw new Error("Method 'getCompletion' must be implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {sendCompletion} */
|
||||||
async sendCompletion() {
|
async sendCompletion() {
|
||||||
throw new Error("Method 'sendCompletion' must be implemented.");
|
throw new Error("Method 'sendCompletion' must be implemented.");
|
||||||
}
|
}
|
||||||
|
|
@ -689,8 +690,7 @@ class BaseClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {string|string[]|undefined} */
|
const { completion, metadata } = await this.sendCompletion(payload, opts);
|
||||||
const completion = await this.sendCompletion(payload, opts);
|
|
||||||
if (this.abortController) {
|
if (this.abortController) {
|
||||||
this.abortController.requestCompleted = true;
|
this.abortController.requestCompleted = true;
|
||||||
}
|
}
|
||||||
|
|
@ -708,6 +708,7 @@ class BaseClient {
|
||||||
iconURL: this.options.iconURL,
|
iconURL: this.options.iconURL,
|
||||||
endpoint: this.options.endpoint,
|
endpoint: this.options.endpoint,
|
||||||
...(this.metadata ?? {}),
|
...(this.metadata ?? {}),
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof completion === 'string') {
|
if (typeof completion === 'string') {
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: ContentTypes.TEXT,
|
type: ContentTypes.TEXT,
|
||||||
[ContentTypes.TEXT]: 'I\'ll search for that information.',
|
[ContentTypes.TEXT]: "I'll search for that information.",
|
||||||
tool_call_ids: ['search_1'],
|
tool_call_ids: ['search_1'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: ContentTypes.TEXT,
|
type: ContentTypes.TEXT,
|
||||||
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.',
|
[ContentTypes.TEXT]: "Now, I'll convert the temperature.",
|
||||||
tool_call_ids: ['convert_1'],
|
tool_call_ids: ['convert_1'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
|
||||||
output: '23.89°C',
|
output: '23.89°C',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' },
|
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -171,7 +171,7 @@ describe('formatAgentMessages', () => {
|
||||||
expect(result[4]).toBeInstanceOf(AIMessage);
|
expect(result[4]).toBeInstanceOf(AIMessage);
|
||||||
|
|
||||||
// Check first AIMessage
|
// Check first AIMessage
|
||||||
expect(result[0].content).toBe('I\'ll search for that information.');
|
expect(result[0].content).toBe("I'll search for that information.");
|
||||||
expect(result[0].tool_calls).toHaveLength(1);
|
expect(result[0].tool_calls).toHaveLength(1);
|
||||||
expect(result[0].tool_calls[0]).toEqual({
|
expect(result[0].tool_calls[0]).toEqual({
|
||||||
id: 'search_1',
|
id: 'search_1',
|
||||||
|
|
@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check second AIMessage
|
// Check second AIMessage
|
||||||
expect(result[2].content).toBe('Now, I\'ll convert the temperature.');
|
expect(result[2].content).toBe("Now, I'll convert the temperature.");
|
||||||
expect(result[2].tool_calls).toHaveLength(1);
|
expect(result[2].tool_calls).toHaveLength(1);
|
||||||
expect(result[2].tool_calls[0]).toEqual({
|
expect(result[2].tool_calls[0]).toEqual({
|
||||||
id: 'convert_1',
|
id: 'convert_1',
|
||||||
|
|
@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
|
||||||
|
|
||||||
// Check final AIMessage
|
// Check final AIMessage
|
||||||
expect(result[4].content).toStrictEqual([
|
expect(result[4].content).toStrictEqual([
|
||||||
{ [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -217,7 +217,7 @@ describe('formatAgentMessages', () => {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
|
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
|
||||||
},
|
},
|
||||||
{ role: 'user', content: 'What\'s the weather?' },
|
{ role: 'user', content: "What's the weather?" },
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [
|
||||||
|
|
@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [
|
||||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' },
|
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -265,12 +265,12 @@ describe('formatAgentMessages', () => {
|
||||||
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
expect(result[2].content).toStrictEqual([
|
expect(result[2].content).toStrictEqual([
|
||||||
{ [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
expect(result[3].content).toBe('Let me check that for you.');
|
expect(result[3].content).toBe('Let me check that for you.');
|
||||||
expect(result[4].content).toBe('Sunny, 75°F');
|
expect(result[4].content).toBe('Sunny, 75°F');
|
||||||
expect(result[5].content).toStrictEqual([
|
expect(result[5].content).toStrictEqual([
|
||||||
{ [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT },
|
{ [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check that there are no consecutive AIMessages
|
// Check that there are no consecutive AIMessages
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,10 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
TestClient.sendCompletion = jest.fn(async () => {
|
TestClient.sendCompletion = jest.fn(async () => {
|
||||||
return 'Mock response text';
|
return {
|
||||||
|
completion: 'Mock response text',
|
||||||
|
metadata: undefined,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {
|
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
"@langchain/google-genai": "^0.2.13",
|
"@langchain/google-genai": "^0.2.13",
|
||||||
"@langchain/google-vertexai": "^0.2.13",
|
"@langchain/google-vertexai": "^0.2.13",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^3.0.17",
|
"@librechat/agents": "^3.0.20",
|
||||||
"@librechat/api": "*",
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,9 @@ function disposeClient(client) {
|
||||||
if (client.agentConfigs) {
|
if (client.agentConfigs) {
|
||||||
client.agentConfigs = null;
|
client.agentConfigs = null;
|
||||||
}
|
}
|
||||||
|
if (client.agentIdMap) {
|
||||||
|
client.agentIdMap = null;
|
||||||
|
}
|
||||||
if (client.artifactPromises) {
|
if (client.artifactPromises) {
|
||||||
client.artifactPromises = null;
|
client.artifactPromises = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const {
|
||||||
Providers,
|
Providers,
|
||||||
TitleMethod,
|
TitleMethod,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
|
labelContentByAgent,
|
||||||
formatAgentMessages,
|
formatAgentMessages,
|
||||||
getTokenCountForMessage,
|
getTokenCountForMessage,
|
||||||
createMetadataAggregator,
|
createMetadataAggregator,
|
||||||
|
|
@ -92,6 +93,61 @@ function logToolError(graph, error, toolId) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies agent labeling to conversation history when multi-agent patterns are detected.
|
||||||
|
* Labels content parts by their originating agent to prevent identity confusion.
|
||||||
|
*
|
||||||
|
* @param {TMessage[]} orderedMessages - The ordered conversation messages
|
||||||
|
* @param {Agent} primaryAgent - The primary agent configuration
|
||||||
|
* @param {Map<string, Agent>} agentConfigs - Map of additional agent configurations
|
||||||
|
* @returns {TMessage[]} Messages with agent labels applied where appropriate
|
||||||
|
*/
|
||||||
|
function applyAgentLabelsToHistory(orderedMessages, primaryAgent, agentConfigs) {
|
||||||
|
const shouldLabelByAgent = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0;
|
||||||
|
|
||||||
|
if (!shouldLabelByAgent) {
|
||||||
|
return orderedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedMessages = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < orderedMessages.length; i++) {
|
||||||
|
const message = orderedMessages[i];
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' };
|
||||||
|
|
||||||
|
if (agentConfigs) {
|
||||||
|
for (const [agentId, agentConfig] of agentConfigs.entries()) {
|
||||||
|
agentNames[agentId] = agentConfig.name || agentConfig.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!message.isCreatedByUser &&
|
||||||
|
message.metadata?.agentIdMap &&
|
||||||
|
Array.isArray(message.content)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const labeledContent = labelContentByAgent(
|
||||||
|
message.content,
|
||||||
|
message.metadata.agentIdMap,
|
||||||
|
agentNames,
|
||||||
|
);
|
||||||
|
|
||||||
|
processedMessages.push({ ...message, content: labeledContent });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[AgentClient] Error applying agent labels to message:', error);
|
||||||
|
processedMessages.push(message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
processedMessages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
class AgentClient extends BaseClient {
|
class AgentClient extends BaseClient {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
super(null, options);
|
super(null, options);
|
||||||
|
|
@ -141,6 +197,8 @@ class AgentClient extends BaseClient {
|
||||||
this.indexTokenCountMap = {};
|
this.indexTokenCountMap = {};
|
||||||
/** @type {(messages: BaseMessage[]) => Promise<void>} */
|
/** @type {(messages: BaseMessage[]) => Promise<void>} */
|
||||||
this.processMemory;
|
this.processMemory;
|
||||||
|
/** @type {Record<number, string> | null} */
|
||||||
|
this.agentIdMap = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -233,6 +291,12 @@ class AgentClient extends BaseClient {
|
||||||
summary: this.shouldSummarize,
|
summary: this.shouldSummarize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
orderedMessages = applyAgentLabelsToHistory(
|
||||||
|
orderedMessages,
|
||||||
|
this.options.agent,
|
||||||
|
this.agentConfigs,
|
||||||
|
);
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
/** @type {number | undefined} */
|
/** @type {number | undefined} */
|
||||||
let promptTokens;
|
let promptTokens;
|
||||||
|
|
@ -612,7 +676,11 @@ class AgentClient extends BaseClient {
|
||||||
userMCPAuthMap: opts.userMCPAuthMap,
|
userMCPAuthMap: opts.userMCPAuthMap,
|
||||||
abortController: opts.abortController,
|
abortController: opts.abortController,
|
||||||
});
|
});
|
||||||
return filterMalformedContentParts(this.contentParts);
|
|
||||||
|
const completion = filterMalformedContentParts(this.contentParts);
|
||||||
|
const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined;
|
||||||
|
|
||||||
|
return { completion, metadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -902,6 +970,24 @@ class AgentClient extends BaseClient {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** Capture agent ID map if we have edges or multiple agents */
|
||||||
|
const shouldStoreAgentMap =
|
||||||
|
(this.options.agent.edges?.length ?? 0) > 0 || (this.agentConfigs?.size ?? 0) > 0;
|
||||||
|
if (shouldStoreAgentMap && run?.Graph) {
|
||||||
|
const contentPartAgentMap = run.Graph.getContentPartAgentMap();
|
||||||
|
if (contentPartAgentMap && contentPartAgentMap.size > 0) {
|
||||||
|
this.agentIdMap = Object.fromEntries(contentPartAgentMap);
|
||||||
|
logger.debug('[AgentClient] Captured agent ID map:', {
|
||||||
|
totalParts: this.contentParts.length,
|
||||||
|
mappedParts: Object.keys(this.agentIdMap).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[AgentClient] Error capturing agent ID map:', error);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||||
|
|
@ -935,6 +1021,9 @@ class AgentClient extends BaseClient {
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
run = null;
|
||||||
|
config = null;
|
||||||
|
memoryPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1828,7 +1828,7 @@
|
||||||
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
|
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
|
||||||
* @param {AbortController} opts.abortController - AbortController instance
|
* @param {AbortController} opts.abortController - AbortController instance
|
||||||
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
|
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
|
||||||
* @returns {Promise<string>}
|
* @returns {Promise<{ content: Promise<MessageContentComplex[]>; metadata: Record<string, unknown>; }>}
|
||||||
* @memberof typedefs
|
* @memberof typedefs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -63,7 +63,7 @@
|
||||||
"@langchain/google-genai": "^0.2.13",
|
"@langchain/google-genai": "^0.2.13",
|
||||||
"@langchain/google-vertexai": "^0.2.13",
|
"@langchain/google-vertexai": "^0.2.13",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^3.0.17",
|
"@librechat/agents": "^3.0.20",
|
||||||
"@librechat/api": "*",
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||||
|
|
@ -16351,9 +16351,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@librechat/agents": {
|
"node_modules/@librechat/agents": {
|
||||||
"version": "3.0.17",
|
"version": "3.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.20.tgz",
|
||||||
"integrity": "sha512-gnom77oW10cdGmmQ6rExc+Blrfzib9JqrZk2fDPwkSOWXQIK4nMm6vWS+UkhH/YfN996mMnffpWoQQ6QQvkTGg==",
|
"integrity": "sha512-GSFpTYIylN/01c4QksMlsKBkptB4v9qYHzcT7rXxHzj568W7mz0ZPBfs1N6PwZRyp+1RgDwo3v38CS4oQmdOTQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@langchain/anthropic": "^0.3.26",
|
"@langchain/anthropic": "^0.3.26",
|
||||||
|
|
@ -47334,7 +47334,7 @@
|
||||||
"@azure/storage-blob": "^12.27.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
"@langchain/core": "^0.3.79",
|
"@langchain/core": "^0.3.79",
|
||||||
"@librechat/agents": "^3.0.17",
|
"@librechat/agents": "^3.0.20",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@modelcontextprotocol/sdk": "^1.21.0",
|
"@modelcontextprotocol/sdk": "^1.21.0",
|
||||||
"axios": "^1.12.1",
|
"axios": "^1.12.1",
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
"@azure/storage-blob": "^12.27.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
"@langchain/core": "^0.3.79",
|
"@langchain/core": "^0.3.79",
|
||||||
"@librechat/agents": "^3.0.17",
|
"@librechat/agents": "^3.0.20",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@modelcontextprotocol/sdk": "^1.21.0",
|
"@modelcontextprotocol/sdk": "^1.21.0",
|
||||||
"axios": "^1.12.1",
|
"axios": "^1.12.1",
|
||||||
|
|
|
||||||
|
|
@ -610,6 +610,8 @@ export const tMessageSchema = z.object({
|
||||||
/* frontend components */
|
/* frontend components */
|
||||||
iconURL: z.string().nullable().optional(),
|
iconURL: z.string().nullable().optional(),
|
||||||
feedback: feedbackSchema.optional(),
|
feedback: feedbackSchema.optional(),
|
||||||
|
/** metadata */
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MemoryArtifact = {
|
export type MemoryArtifact = {
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ const messageSchema: Schema<IMessage> = new Schema(
|
||||||
iconURL: {
|
iconURL: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
metadata: { type: mongoose.Schema.Types.Mixed },
|
||||||
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
|
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
|
||||||
/*
|
/*
|
||||||
attachments: {
|
attachments: {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export interface IMessage extends Document {
|
||||||
content?: unknown[];
|
content?: unknown[];
|
||||||
thread_id?: string;
|
thread_id?: string;
|
||||||
iconURL?: string;
|
iconURL?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
attachments?: unknown[];
|
attachments?: unknown[];
|
||||||
expiredAt?: Date;
|
expiredAt?: Date;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue