/** * Usage Budget Management Service * * Tracks token usage per agent and enforces budget limits. * Provides real-time usage summaries and budget status checks. */ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { UsageBudget, UsageRecord, UsageSummary, AgentUsageSummary, BudgetStatus, } from "./budget.types"; import { DEFAULT_BUDGET } from "./budget.types"; /** Sanitize strings for safe log output */ function sanitizeForLog(value: string): string { return value.replace(/[\n\r\t]/g, "_").slice(0, 128); } @Injectable() export class BudgetService { private readonly logger = new Logger(BudgetService.name); private readonly budget: UsageBudget; private records: UsageRecord[] = []; private readonly activeAgents = new Set(); private lastPurgeDate = ""; constructor(private readonly configService: ConfigService) { this.budget = this.loadAndValidateConfig(); this.logger.log( `BudgetService initialized: daily=${String(this.budget.dailyTokenLimit)} tokens, ` + `perAgent=${String(this.budget.perAgentTokenLimit)} tokens, ` + `maxConcurrent=${String(this.budget.maxConcurrentAgents)}` ); } /** * Record token usage for an agent. * Negative and NaN values are clamped to 0. */ recordUsage(agentId: string, taskId: string, inputTokens: number, outputTokens: number): void { if (!agentId || !taskId) { this.logger.warn("recordUsage called with empty agentId or taskId — skipping"); return; } const safeInput = Math.max(0, Number.isFinite(inputTokens) ? inputTokens : 0); const safeOutput = Math.max(0, Number.isFinite(outputTokens) ? outputTokens : 0); this.purgeStaleRecords(); const record: UsageRecord = { agentId, taskId, inputTokens: safeInput, outputTokens: safeOutput, timestamp: new Date(), }; this.records.push(record); this.logger.debug( `Usage recorded: agent=${sanitizeForLog(agentId)} input=${String(safeInput)} output=${String(safeOutput)}` ); } /** * Check if an agent can be spawned (concurrency and budget check). * When allowed, atomically increments the active agent count. */ trySpawnAgent(agentId: string): { allowed: boolean; reason?: string } { if (!agentId) { return { allowed: false, reason: "Agent ID is required" }; } if (this.activeAgents.size >= this.budget.maxConcurrentAgents) { return { allowed: false, reason: `Maximum concurrent agents reached (${String(this.budget.maxConcurrentAgents)})`, }; } const dailyUsed = this.getDailyTokensUsed(); if (this.budget.enforceHardLimits && dailyUsed >= this.budget.dailyTokenLimit) { return { allowed: false, reason: `Daily token budget exceeded (${String(dailyUsed)}/${String(this.budget.dailyTokenLimit)})`, }; } this.activeAgents.add(agentId); return { allowed: true }; } /** * Check if an agent can be spawned without reserving a slot. */ canSpawnAgent(): { allowed: boolean; reason?: string } { if (this.activeAgents.size >= this.budget.maxConcurrentAgents) { return { allowed: false, reason: `Maximum concurrent agents reached (${String(this.budget.maxConcurrentAgents)})`, }; } const dailyUsed = this.getDailyTokensUsed(); if (this.budget.enforceHardLimits && dailyUsed >= this.budget.dailyTokenLimit) { return { allowed: false, reason: `Daily token budget exceeded (${String(dailyUsed)}/${String(this.budget.dailyTokenLimit)})`, }; } return { allowed: true }; } /** * Check if an agent has exceeded its per-task budget (today only). */ isAgentOverBudget(agentId: string): { overBudget: boolean; totalTokens: number } { const todayStart = this.getTodayStart(); const agentRecords = this.records.filter( (r) => r.agentId === agentId && r.timestamp >= todayStart ); const totalTokens = agentRecords.reduce((sum, r) => sum + r.inputTokens + r.outputTokens, 0); return { overBudget: totalTokens >= this.budget.perAgentTokenLimit, totalTokens, }; } /** * Notify that an agent has started (track by ID). */ agentStarted(agentId: string): void { this.activeAgents.add(agentId); } /** * Notify that an agent has stopped (remove by ID). */ agentStopped(agentId: string): void { this.activeAgents.delete(agentId); } /** * Get comprehensive usage summary */ getUsageSummary(): UsageSummary { this.purgeStaleRecords(); const dailyTokensUsed = this.getDailyTokensUsed(); const dailyUsagePercent = this.budget.dailyTokenLimit > 0 ? (dailyTokensUsed / this.budget.dailyTokenLimit) * 100 : 0; return { dailyTokensUsed, dailyTokenLimit: this.budget.dailyTokenLimit, dailyUsagePercent: Math.round(dailyUsagePercent * 100) / 100, agentUsage: this.getAgentUsageSummaries(), activeAgents: this.activeAgents.size, maxConcurrentAgents: this.budget.maxConcurrentAgents, budgetStatus: this.getBudgetStatus(dailyUsagePercent), }; } /** * Get the configured budget */ getBudget(): UsageBudget { return { ...this.budget }; } /** * Load configuration with validation. Clamps invalid values to defaults. */ private loadAndValidateConfig(): UsageBudget { const raw = { dailyTokenLimit: this.configService.get("orchestrator.budget.dailyTokenLimit") ?? DEFAULT_BUDGET.dailyTokenLimit, perAgentTokenLimit: this.configService.get("orchestrator.budget.perAgentTokenLimit") ?? DEFAULT_BUDGET.perAgentTokenLimit, maxConcurrentAgents: this.configService.get("orchestrator.budget.maxConcurrentAgents") ?? DEFAULT_BUDGET.maxConcurrentAgents, maxTaskDurationMinutes: this.configService.get("orchestrator.budget.maxTaskDurationMinutes") ?? DEFAULT_BUDGET.maxTaskDurationMinutes, enforceHardLimits: this.configService.get("orchestrator.budget.enforceHardLimits") ?? DEFAULT_BUDGET.enforceHardLimits, }; return { dailyTokenLimit: this.clampPositive(raw.dailyTokenLimit, DEFAULT_BUDGET.dailyTokenLimit), perAgentTokenLimit: this.clampPositive( raw.perAgentTokenLimit, DEFAULT_BUDGET.perAgentTokenLimit ), maxConcurrentAgents: this.clampPositiveInt( raw.maxConcurrentAgents, DEFAULT_BUDGET.maxConcurrentAgents ), maxTaskDurationMinutes: this.clampPositiveInt( raw.maxTaskDurationMinutes, DEFAULT_BUDGET.maxTaskDurationMinutes ), enforceHardLimits: raw.enforceHardLimits, }; } private clampPositive(value: number, fallback: number): number { return Number.isFinite(value) && value > 0 ? value : fallback; } private clampPositiveInt(value: number, fallback: number): number { return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; } /** * Purge records from previous days to prevent unbounded memory growth. */ private purgeStaleRecords(): void { const todayStr = new Date().toISOString().slice(0, 10); if (this.lastPurgeDate === todayStr) return; const todayStart = this.getTodayStart(); const before = this.records.length; this.records = this.records.filter((r) => r.timestamp >= todayStart); if (before > this.records.length) { this.logger.log( `Purged ${String(before - this.records.length)} stale usage records from previous days` ); } this.lastPurgeDate = todayStr; } /** * Get total tokens used today using proper Date comparison. */ private getDailyTokensUsed(): number { const todayStart = this.getTodayStart(); return this.records .filter((r) => r.timestamp >= todayStart) .reduce((sum, r) => sum + r.inputTokens + r.outputTokens, 0); } private getTodayStart(): Date { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); return todayStart; } /** * Get per-agent usage summaries (today only). */ private getAgentUsageSummaries(): AgentUsageSummary[] { const todayStart = this.getTodayStart(); const todayRecords = this.records.filter((r) => r.timestamp >= todayStart); const agentMap = new Map(); for (const record of todayRecords) { const existing = agentMap.get(record.agentId); if (existing) { existing.input += record.inputTokens; existing.output += record.outputTokens; } else { agentMap.set(record.agentId, { taskId: record.taskId, input: record.inputTokens, output: record.outputTokens, }); } } return [...agentMap.entries()].map(([agentId, data]) => { const totalTokens = data.input + data.output; const usagePercent = this.budget.perAgentTokenLimit > 0 ? Math.round((totalTokens / this.budget.perAgentTokenLimit) * 10000) / 100 : 0; return { agentId, taskId: data.taskId, inputTokens: data.input, outputTokens: data.output, totalTokens, usagePercent, }; }); } /** * Determine overall budget status */ private getBudgetStatus(dailyUsagePercent: number): BudgetStatus { if (dailyUsagePercent >= 100) return "exceeded"; if (dailyUsagePercent >= 95) return "at_limit"; if (dailyUsagePercent >= 80) return "approaching_limit"; return "within_budget"; } }