@@ -20,6 +20,26 @@ import { buildMcpRuntimeConfig } from './mcp-runtime.js';
2020
2121export { buildMcpRuntimeConfig } ;
2222
23+ /** Compute the start of the current daily budget period (epoch ms). */
24+ function getDailyPeriodStart ( resetHour : number ) : number {
25+ const now = new Date ( ) ;
26+ const start = new Date (
27+ Date . UTC (
28+ now . getUTCFullYear ( ) ,
29+ now . getUTCMonth ( ) ,
30+ now . getUTCDate ( ) ,
31+ resetHour ,
32+ 0 ,
33+ 0 ,
34+ 0 ,
35+ ) ,
36+ ) ;
37+ if ( start . getTime ( ) > now . getTime ( ) ) {
38+ start . setUTCDate ( start . getUTCDate ( ) - 1 ) ;
39+ }
40+ return start . getTime ( ) ;
41+ }
42+
2343function hasWakeTrigger (
2444 messages : Array < { content : string ; sender : string ; is_from_me ?: boolean } > ,
2545 chatJid : string ,
@@ -137,6 +157,11 @@ export class MessageProcessor {
137157 'Processing messages' ,
138158 ) ;
139159
160+ // ── Budget enforcement ───────────────────────���────────────────
161+ const budgetBlocked = await this . checkAndEnforceBudget ( chatJid ) ;
162+ if ( budgetBlocked ) return false ;
163+ // ─────────────────────────────────────────────────────────────
164+
140165 let idleTimer : ReturnType < typeof setTimeout > | null = null ;
141166 const resetIdleTimer = ( ) => {
142167 if ( idleTimer ) clearTimeout ( idleTimer ) ;
@@ -322,6 +347,129 @@ export class MessageProcessor {
322347 return true ;
323348 }
324349
350+ /**
351+ * Check whether this group's token budget is exceeded or warning-level.
352+ * Returns true if the agent should be blocked (budget exceeded), false otherwise.
353+ * Emits 'budget.exceeded' or 'budget.warning' events as appropriate.
354+ * Sends a user-facing message when pausing.
355+ */
356+ private async checkAndEnforceBudget ( chatJid : string ) : Promise < boolean > {
357+ const config = this . ctx . db . getBudgetConfig ( chatJid ) ;
358+ if (
359+ ! config ||
360+ ( config . daily_limit_usd == null && config . total_limit_usd == null )
361+ ) {
362+ return false ; // no budget configured
363+ }
364+
365+ const state = this . ctx . db . getBudgetState ( chatJid ) ;
366+ const now = new Date ( ) . toISOString ( ) ;
367+
368+ // If already paused, block immediately.
369+ if ( state . paused ) {
370+ this . ctx . emit ( 'budget.exceeded' , {
371+ agentId : this . ctx . id ,
372+ jid : chatJid ,
373+ limitType : state . paused_reason === 'total_limit' ? 'total' : 'daily' ,
374+ limitUsd :
375+ state . paused_reason === 'total_limit'
376+ ? ( config . total_limit_usd ?? 0 )
377+ : ( config . daily_limit_usd ?? 0 ) ,
378+ usedUsd : 0 ,
379+ timestamp : now ,
380+ } ) ;
381+ const channel = findChannel ( this . channelMgr . channelArray , chatJid ) ;
382+ await channel ?. sendMessage ?.(
383+ chatJid ,
384+ 'Agent paused: token budget exceeded. Resume from Dune settings.' ,
385+ ) ;
386+ return true ;
387+ }
388+
389+ const periodStart = getDailyPeriodStart ( config . reset_hour ) ;
390+ const dailyCost = this . ctx . db . getDailyUsageUsd ( chatJid , periodStart ) ;
391+ const totalCost = this . ctx . db . getTotalUsageUsd ( chatJid ) ;
392+
393+ // Check hard limits.
394+ if (
395+ ( config . daily_limit_usd != null && dailyCost >= config . daily_limit_usd ) ||
396+ ( config . total_limit_usd != null && totalCost >= config . total_limit_usd )
397+ ) {
398+ const isDaily =
399+ config . daily_limit_usd != null && dailyCost >= config . daily_limit_usd ;
400+ const limitType : 'daily' | 'total' = isDaily ? 'daily' : 'total' ;
401+ const limitUsd = isDaily
402+ ? config . daily_limit_usd !
403+ : config . total_limit_usd ! ;
404+ const usedUsd = isDaily ? dailyCost : totalCost ;
405+
406+ this . ctx . db . setBudgetPaused (
407+ chatJid ,
408+ `${ limitType } _limit` as 'daily_limit' | 'total_limit' ,
409+ ) ;
410+ this . ctx . emit ( 'budget.exceeded' , {
411+ agentId : this . ctx . id ,
412+ jid : chatJid ,
413+ limitType,
414+ limitUsd,
415+ usedUsd,
416+ timestamp : now ,
417+ } ) ;
418+
419+ const channel = findChannel ( this . channelMgr . channelArray , chatJid ) ;
420+ await channel ?. sendMessage ?.(
421+ chatJid ,
422+ `Agent paused: ${ limitType } token budget exceeded ($${ usedUsd . toFixed ( 4 ) } of $${ limitUsd . toFixed ( 2 ) } used). Resume from Dune settings.` ,
423+ ) ;
424+
425+ logger . warn (
426+ { chatJid, limitType, limitUsd, usedUsd, agent : this . ctx . name } ,
427+ 'Budget exceeded — agent paused' ,
428+ ) ;
429+ return true ;
430+ }
431+
432+ // Check warning threshold (80%).
433+ const warnDaily =
434+ config . daily_limit_usd != null &&
435+ dailyCost >= 0.8 * config . daily_limit_usd ;
436+ const warnTotal =
437+ config . total_limit_usd != null &&
438+ totalCost >= 0.8 * config . total_limit_usd ;
439+
440+ if ( warnDaily || warnTotal ) {
441+ const isDaily = warnDaily ;
442+ const limitType : 'daily' | 'total' = isDaily ? 'daily' : 'total' ;
443+ const limitUsd = isDaily
444+ ? config . daily_limit_usd !
445+ : config . total_limit_usd ! ;
446+ const usedUsd = isDaily ? dailyCost : totalCost ;
447+ const pctUsed = usedUsd / limitUsd ;
448+
449+ this . ctx . emit ( 'budget.warning' , {
450+ agentId : this . ctx . id ,
451+ jid : chatJid ,
452+ pctUsed,
453+ limitType,
454+ limitUsd,
455+ usedUsd,
456+ timestamp : now ,
457+ } ) ;
458+
459+ logger . info (
460+ {
461+ chatJid,
462+ limitType,
463+ pctUsed : Math . round ( pctUsed * 100 ) ,
464+ agent : this . ctx . name ,
465+ } ,
466+ 'Budget warning — approaching limit' ,
467+ ) ;
468+ }
469+
470+ return false ; // proceed normally
471+ }
472+
325473 /** Execute agent in a container for the given group. */
326474 async runAgent (
327475 group : InternalRegisteredGroup ,
0 commit comments