feat(FN-1263): add agent budget tracking and reset APIs
- Add AgentBudgetConfig and AgentBudgetStatus types to runtime config and export them from @fusion/core - Implement AgentStore.getBudgetStatus to compute usage, thresholds, over-budget flags, and next reset timestamps for daily/weekly/monthly/lifetime periods - Implement AgentStore.resetBudgetUsage to zero token counters, record budgetResetAt, and emit agent updates while preserving other runtime settings - Add comprehensive AgentStore budget management tests covering no-budget behavior, threshold calculations, reset behavior, and reset-day edge cases
This commit is contained in:
@@ -29,6 +29,8 @@ import type {
|
||||
AgentHeartbeatEvent,
|
||||
AgentHeartbeatRun,
|
||||
AgentDetail,
|
||||
AgentBudgetConfig,
|
||||
AgentBudgetStatus,
|
||||
AgentTaskSession,
|
||||
AgentConfigRevision,
|
||||
AgentConfigSnapshot,
|
||||
@@ -211,6 +213,59 @@ export class AgentStore extends EventEmitter {
|
||||
return computeAccessState(agent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get computed budget usage status for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @returns Computed budget usage status
|
||||
* @throws Error if agent not found
|
||||
*/
|
||||
async getBudgetStatus(agentId: string): Promise<AgentBudgetStatus> {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const totalInputTokens = agent.totalInputTokens ?? 0;
|
||||
const totalOutputTokens = agent.totalOutputTokens ?? 0;
|
||||
const currentUsage = totalInputTokens + totalOutputTokens;
|
||||
|
||||
const runtimeConfig = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
|
||||
const budgetConfig = runtimeConfig.budgetConfig as AgentBudgetConfig | undefined;
|
||||
const rawLastResetAt = runtimeConfig.budgetResetAt;
|
||||
const lastResetAt = typeof rawLastResetAt === "string" ? rawLastResetAt : null;
|
||||
|
||||
if (!budgetConfig || budgetConfig.tokenBudget === undefined) {
|
||||
return {
|
||||
agentId,
|
||||
currentUsage,
|
||||
budgetLimit: null,
|
||||
usagePercent: null,
|
||||
thresholdPercent: null,
|
||||
isOverBudget: false,
|
||||
isOverThreshold: false,
|
||||
lastResetAt,
|
||||
nextResetAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const tokenBudget = budgetConfig.tokenBudget;
|
||||
const usagePercent = Math.min((currentUsage / tokenBudget) * 100, 100);
|
||||
const usageThreshold = budgetConfig.usageThreshold ?? 0.8;
|
||||
const thresholdPercent = usageThreshold * 100;
|
||||
|
||||
return {
|
||||
agentId,
|
||||
currentUsage,
|
||||
budgetLimit: tokenBudget,
|
||||
usagePercent,
|
||||
thresholdPercent,
|
||||
isOverBudget: currentUsage >= tokenBudget,
|
||||
isOverThreshold: usagePercent >= thresholdPercent,
|
||||
lastResetAt,
|
||||
nextResetAt: this.computeNextResetAt(budgetConfig.budgetPeriod, budgetConfig.resetDay),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed agent info including heartbeat history.
|
||||
* @param agentId - The agent ID
|
||||
@@ -752,6 +807,35 @@ export class AgentStore extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset budget token usage counters for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @throws Error if agent not found
|
||||
*/
|
||||
async resetBudgetUsage(agentId: string): Promise<void> {
|
||||
await this.withLock(agentId, async () => {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const budgetResetAt = new Date().toISOString();
|
||||
const updated: Agent = {
|
||||
...agent,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
runtimeConfig: {
|
||||
...(agent.runtimeConfig ?? {}),
|
||||
budgetResetAt,
|
||||
},
|
||||
updatedAt: budgetResetAt,
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
this.emit("agent:updated", updated);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset an agent from any state back to "idle".
|
||||
* Clears transient execution state (taskId, lastError, pauseReason)
|
||||
@@ -1499,6 +1583,61 @@ export class AgentStore extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private computeNextResetAt(period: AgentBudgetConfig["budgetPeriod"], resetDay?: number): string | null {
|
||||
if (!period || period === "lifetime") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (period === "daily") {
|
||||
const nextMidnight = new Date(now);
|
||||
nextMidnight.setHours(0, 0, 0, 0);
|
||||
nextMidnight.setDate(nextMidnight.getDate() + 1);
|
||||
return nextMidnight.toISOString();
|
||||
}
|
||||
|
||||
if (period === "weekly") {
|
||||
const normalizedResetDay =
|
||||
typeof resetDay === "number" && Number.isFinite(resetDay)
|
||||
? Math.max(0, Math.min(6, Math.floor(resetDay)))
|
||||
: 0;
|
||||
const nextWeeklyReset = new Date(now);
|
||||
nextWeeklyReset.setHours(0, 0, 0, 0);
|
||||
|
||||
const currentDay = nextWeeklyReset.getDay();
|
||||
let daysUntilReset = (normalizedResetDay - currentDay + 7) % 7;
|
||||
if (daysUntilReset === 0) {
|
||||
daysUntilReset = 7;
|
||||
}
|
||||
|
||||
nextWeeklyReset.setDate(nextWeeklyReset.getDate() + daysUntilReset);
|
||||
return nextWeeklyReset.toISOString();
|
||||
}
|
||||
|
||||
if (period === "monthly") {
|
||||
const normalizedResetDay =
|
||||
typeof resetDay === "number" && Number.isFinite(resetDay)
|
||||
? Math.max(1, Math.min(31, Math.floor(resetDay)))
|
||||
: 1;
|
||||
|
||||
const createMonthlyReset = (year: number, month: number): Date => {
|
||||
const lastDayOfMonth = new Date(year, month + 1, 0).getDate();
|
||||
const clampedResetDay = Math.min(normalizedResetDay, lastDayOfMonth);
|
||||
return new Date(year, month, clampedResetDay, 0, 0, 0, 0);
|
||||
};
|
||||
|
||||
let nextMonthlyReset = createMonthlyReset(now.getFullYear(), now.getMonth());
|
||||
if (nextMonthlyReset <= now) {
|
||||
nextMonthlyReset = createMonthlyReset(now.getFullYear(), now.getMonth() + 1);
|
||||
}
|
||||
|
||||
return nextMonthlyReset.toISOString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getBundleDir(agentId: string): string {
|
||||
return join(this.agentsDir, `${agentId}-instructions`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user