Skip to content

Commit

Permalink
Assistants usage (#2959)
Browse files Browse the repository at this point in the history
* Write side of assistants usage

* WIP

* Fully functional + UI

* Clean up

* Clean up after self review

* signal agent usage when editing a message

* Update front/pages/api/w/[wId]/assistant/agent_configurations/[aId]/usage.ts

Co-authored-by: Philippe Rolet <[email protected]>

* Clean up

* Message plural

---------

Co-authored-by: Philippe Rolet <[email protected]>
  • Loading branch information
lasryaric and philipperolet authored Dec 22, 2023
1 parent 705a1a6 commit eb5c21f
Show file tree
Hide file tree
Showing 7 changed files with 431 additions and 2 deletions.
42 changes: 41 additions & 1 deletion front/components/assistant/AssistantDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
XMarkIcon,
} from "@dust-tt/sparkle";
import {
AgentUsageType,
AgentUserListStatus,
ConnectorProvider,
DatabaseQueryConfigurationType,
Expand All @@ -33,7 +34,7 @@ import ReactMarkdown from "react-markdown";
import { DeleteAssistantDialog } from "@app/components/assistant/AssistantActions";
import { SendNotificationsContext } from "@app/components/sparkle/Notification";
import { CONNECTOR_CONFIGURATIONS } from "@app/lib/connector_providers";
import { useApp, useDatabase } from "@app/lib/swr";
import { useAgentUsage, useApp, useDatabase } from "@app/lib/swr";
import { PostAgentListStatusRequestBody } from "@app/pages/api/w/[wId]/members/me/agent_list_status";

type AssistantDetailsFlow = "personal" | "workspace";
Expand All @@ -53,6 +54,10 @@ export function AssistantDetails({
onUpdate: () => void;
flow: AssistantDetailsFlow;
}) {
const agentUsage = useAgentUsage({
workspaceId: owner.sId,
agentConfigurationId: assistant.sId,
});
const DescriptionSection = () => (
<div className="flex flex-col gap-4 sm:flex-row">
<Avatar
Expand All @@ -73,6 +78,36 @@ export function AssistantDetails({
"This assistant has no instructions."
);

const UsageSection = ({
usage,
isLoading,
isError,
}: {
usage: AgentUsageType | null;
isLoading: boolean;
isError: boolean;
}) => (
<div className="flex flex-col gap-2">
<div className="text-lg font-bold text-element-800">Usage</div>
{(() => {
if (isError) {
return "Error loading usage data.";
} else if (isLoading) {
return "Loading usage data...";
} else if (usage) {
return (
<>
@{assistant.name} has been used by {usage.userCount} people in{" "}
{usage.messageCount}{" "}
{usage.messageCount > 1 ? <>messages</> : <>message</>} over the
last {usage.timePeriodSec / (60 * 60 * 24)} days.
</>
);
}
})()}
</div>
);

const ActionSection = () =>
assistant.action ? (
isDustAppRunConfiguration(assistant.action) ? (
Expand Down Expand Up @@ -119,6 +154,11 @@ export function AssistantDetails({
/>
<DescriptionSection />
<InstructionsSection />
<UsageSection
usage={agentUsage.agentUsage}
isLoading={agentUsage.isAgentUsageLoading}
isError={agentUsage.isAgentUsageError}
/>
<ActionSection />
</div>
</Modal>
Expand Down
254 changes: 254 additions & 0 deletions front/lib/api/assistant/agent_usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { AgentUsageType, ModelId } from "@dust-tt/types";
import { literal, Op } from "sequelize";
import { v4 as uuidv4 } from "uuid";

import {
Conversation as DBConversation,
Mention,
Message,
UserMessage,
Workspace,
} from "@app/lib/models";
import { redisClient } from "@app/lib/redis";

// Ranking of agents is done over a 30 days period.
const rankingTimeframeSec = 60 * 60 * 24 * 30; // 30 days

function _getKeys({
workspaceId,
agentConfigurationId,
}: {
workspaceId: string;
agentConfigurationId: string;
}) {
// One sorted set per agent for counting the number of times the agent has been used.
// score is: timestamp of each use of the agent
// value: random unique distinct value.
const agentMessageCountKey = `agent_usage_count_${workspaceId}_${agentConfigurationId}`;

// One sorted set per agent for counting the number of users that have used the agent.
// score is: timestamp of last usage by a given user
// value: user_id
const agentUserCountKey = `agent_user_count_${workspaceId}_${agentConfigurationId}`;
return {
agentMessageCountKey,
agentUserCountKey,
};
}

async function signalInRedis({
agentConfigurationId,
workspaceId,
userId,
timestamp,
redis,
}: {
agentConfigurationId: string;
workspaceId: string;
userId: string;
timestamp: number;
messageId: ModelId;
redis: Awaited<ReturnType<typeof redisClient>>;
}) {
const { agentMessageCountKey, agentUserCountKey } = _getKeys({
workspaceId,
agentConfigurationId,
});

await redis.zAdd(agentMessageCountKey, {
score: timestamp,
value: uuidv4(),
});
await redis.expire(agentMessageCountKey, rankingTimeframeSec);

await redis.zAdd(agentUserCountKey, {
score: timestamp,
value: userId,
});
await redis.expire(agentUserCountKey, rankingTimeframeSec);
}

async function populateUsageIfNeeded({
agentConfigurationId,
workspaceId,
messageId,
redis,
}: {
agentConfigurationId: string;
workspaceId: string;
messageId: ModelId | null;
redis: Awaited<ReturnType<typeof redisClient>>;
}) {
const owner = await Workspace.findOne({
where: {
sId: workspaceId,
},
});
if (!owner) {
throw new Error(`Workspace ${workspaceId} not found`);
}
const { agentMessageCountKey, agentUserCountKey } = _getKeys({
agentConfigurationId,
workspaceId,
});

const existCount = await redis.exists([
agentMessageCountKey,
agentUserCountKey,
]);
if (existCount === 0) {
// Sorted sets for this agent usage do not exist, we'll populate them
// by fetching the data from the database.
// We need to ensure that only one process is going through the populate code path
// so we are using redis.incr() to act as a non blocking lock.
const populateLockKey = `agent_usage_populate_${workspaceId}_${agentConfigurationId}`;
const needToPopulate = (await redis.incr(populateLockKey)) === 1;

// Keeping the lock key around for 10 minutes, which essentially gives 10 minutes
// to create the sorted sets, before running the risk of a race conditions.
// A race condition in creating the sorted sets would result in double counting
// usage of the agent.
const populateTimeoutSec = 60 * 10; // 10 minutes
await redis.expire(populateLockKey, populateTimeoutSec);
if (!needToPopulate) {
return;
}

// We are safe to populate the sorted sets until the Redis populateLockKey expires.
// Get all mentions for this agent that have a messageId smaller than messageId
// and that happened within the last 30 days.
const mentions = await Mention.findAll({
where: {
...{
agentConfigurationId: agentConfigurationId,
createdAt: {
[Op.gt]: literal(`NOW() - INTERVAL '30 days'`),
},
},
...(messageId ? { messageId: { [Op.lt]: messageId } } : {}),
},
include: [
{
model: Message,
required: true,
include: [
{
model: UserMessage,
as: "userMessage",
required: true,
},
{
model: DBConversation,
as: "conversation",
required: true,
where: {
workspaceId: owner.id,
},
},
],
},
],
});
for (const mention of mentions) {
// No need to promise.all() here, as one Redis connection can only execute one command
// at a time.
if (mention.message?.userMessage) {
await signalInRedis({
agentConfigurationId,
workspaceId,
userId:
mention.message.userMessage.userId?.toString() ||
mention.message.userMessage.userContextEmail ||
mention.message.userMessage.userContextUsername,
timestamp: mention.createdAt.getTime(),
messageId: mention.messageId,
redis,
});
}
}
}
}

export async function getAgentUsage({
workspaceId,
agentConfigurationId,
}: {
workspaceId: string;
agentConfigurationId: string;
}): Promise<AgentUsageType> {
let redis: Awaited<ReturnType<typeof redisClient>> | null = null;

const { agentMessageCountKey, agentUserCountKey } = _getKeys({
agentConfigurationId,
workspaceId,
});

try {
redis = await redisClient();
await populateUsageIfNeeded({
agentConfigurationId,
workspaceId,
messageId: null,
redis,
});
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 1000 * rankingTimeframeSec);
const messageCount = await redis.zCount(
agentMessageCountKey,
thirtyDaysAgo.getTime(),
now.getTime()
);
const userCount = await redis.zCount(
agentUserCountKey,
thirtyDaysAgo.getTime(),
now.getTime()
);

return {
messageCount,
userCount,
timePeriodSec: rankingTimeframeSec,
};
} finally {
if (redis) {
await redis.quit();
}
}
}

export async function signalAgentUsage({
agentConfigurationId,
workspaceId,
userId,
timestamp,
messageId,
}: {
agentConfigurationId: string;
workspaceId: string;
userId: string;
timestamp: number;
messageId: ModelId;
}) {
let redis: Awaited<ReturnType<typeof redisClient>> | null = null;
try {
redis = await redisClient();
await populateUsageIfNeeded({
agentConfigurationId,
workspaceId,
messageId,
redis,
});
await signalInRedis({
agentConfigurationId,
workspaceId,
userId,
timestamp,
messageId,
redis,
});
} finally {
if (redis) {
await redis.quit();
}
}
}
28 changes: 27 additions & 1 deletion front/lib/api/assistant/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { Op, Transaction } from "sequelize";

import { runActionStreamed } from "@app/lib/actions/server";
import { runAgent } from "@app/lib/api/assistant/agent";
import { signalAgentUsage } from "@app/lib/api/assistant/agent_usage";
import { getAgentConfiguration } from "@app/lib/api/assistant/configuration";
import { renderConversationForModel } from "@app/lib/api/assistant/generation";
import { Authenticator } from "@app/lib/auth";
Expand Down Expand Up @@ -1055,7 +1056,17 @@ export async function* postUserMessage(
if (agentMessageRows.length !== agentMessages.length) {
throw new Error("Unreachable: agentMessageRows and agentMessages mismatch");
}

if (agentMessages.length > 0) {
for (const agentMessage of agentMessages) {
void signalAgentUsage({
userId: user?.id.toString() || context.email || context.username,
agentConfigurationId: agentMessage.configuration.sId,
workspaceId: owner.sId,
messageId: agentMessage.id,
timestamp: agentMessage.created,
});
}
}
yield {
type: "user_message_new",
created: Date.now(),
Expand Down Expand Up @@ -1534,6 +1545,21 @@ export async function* editUserMessage(
message: userMessage,
};

if (agentMessages.length > 0) {
for (const agentMessage of agentMessages) {
void signalAgentUsage({
userId:
user?.id.toString() ||
message.context.email ||
message.context.username,
agentConfigurationId: agentMessage.configuration.sId,
messageId: agentMessage.id,
timestamp: agentMessage.created,
workspaceId: owner.sId,
});
}
}

for (let i = 0; i < agentMessages.length; i++) {
const agentMessage = agentMessages[i];

Expand Down
1 change: 1 addition & 0 deletions front/lib/models/assistant/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ export class Mention extends Model<
declare userId: ForeignKey<User["id"]> | null;
declare agentConfigurationId: string | null; // Not a relation as global agents are not in the DB

declare message: NonAttribute<Message>;
declare user?: NonAttribute<User>;
}

Expand Down
Loading

0 comments on commit eb5c21f

Please sign in to comment.