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:
gsxdsm
2026-04-08 11:42:39 -07:00
parent 13728878fa
commit d3569361d9
4 changed files with 502 additions and 1 deletions

View File

@@ -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`);
}