44 Context ,
55 State ,
66} from "@ag-ui/client" ;
7+ import { Throttler } from "@tanstack/pacer" ;
78import {
89 FrontendTool ,
910 SuggestionsConfig ,
@@ -445,9 +446,9 @@ export class CopilotKitCore {
445446 }
446447
447448 /**
448- * Default throttle interval (ms) used by `subscribeToAgentWithOptions()` when the
449- * caller does not specify an explicit `throttleMs`. `undefined` means no
450- * default is configured; `0` means no throttling.
449+ * Default throttle interval (ms) used by `subscribeToAgentWithOptions()`
450+ * when the caller does not specify an explicit `throttleMs`.
451+ * `undefined` means no default is configured; `0` means no throttling.
451452 */
452453 get defaultThrottleMs ( ) : number | undefined {
453454 return this . _defaultThrottleMs ;
@@ -645,23 +646,14 @@ export class CopilotKitCore {
645646 }
646647
647648 /**
648- * Subscribe to an agent's notification and lifecycle events with optional
649- * throttling on `onMessagesChanged` and `onStateChanged` .
649+ * Subscribe to an agent's notification and lifecycle events with
650+ * optional configuration (e.g. throttling) .
650651 *
651- * Resolves effective throttle: `options.throttleMs ?? defaultThrottleMs ?? 0`.
652- * When > 0, uses a leading+trailing pattern: first notification fires
653- * immediately, subsequent ones within the window are coalesced, and a
654- * trailing timer ensures the most recent update fires after the window.
655- * Both `onMessagesChanged` and `onStateChanged` share a single throttle
656- * window; a notification from either channel opens the window.
657- *
658- * Run lifecycle callbacks always fire immediately — they are never throttled.
659- * Every callback is wrapped with error protection (see `safeCall`).
652+ * Wraps every callback with error protection (`safeCall`) and applies
653+ * the options before delegating to `agent.subscribe()`.
660654 *
661655 * See {@link SubscribeToAgentSubscriber} for the accepted callback subset
662656 * and the rationale for excluding AG-UI event handlers.
663- *
664- * The returned `unsubscribe()` clears any pending trailing timer.
665657 */
666658 subscribeToAgentWithOptions (
667659 agent : AbstractAgent ,
@@ -696,16 +688,9 @@ export class CopilotKitCore {
696688
697689 const agentLabel = agent . agentId || "(unknown agent)" ;
698690
699- // Invoke a subscriber callback safely: catches synchronous throws and
700- // attaches a .catch() for async (MaybePromise) rejections. Returns the
701- // callback's return value on the success path. On the error path, the
702- // synchronous path returns `undefined`; the async path returns a
703- // resolved `Promise<undefined>` (the original rejection is swallowed).
704- // Any return value the callback intended to produce (including lifecycle
705- // `AgentStateMutation`) is intentionally discarded, since there is no
706- // safe way to produce a partial mutation from a failed callback. Errors
707- // are logged AND emitted through the structured `emitError` channel so
708- // monitoring systems can observe subscriber failures.
691+ // Wraps a callback so that synchronous throws and async rejections are
692+ // caught, logged, and emitted — preventing one failing callback from
693+ // corrupting the agent's notification loop.
709694 // eslint-disable-next-line @typescript-eslint/no-explicit-any
710695 const safeCall = < F extends ( ...args : any [ ] ) => any > (
711696 label : string ,
@@ -739,8 +724,6 @@ export class CopilotKitCore {
739724 }
740725 } ;
741726
742- // Wrap every allowed callback in the subscriber with safeCall so errors
743- // in any callback cannot corrupt the agent's notification loop.
744727 const guardAll = (
745728 sub : SubscribeToAgentSubscriber ,
746729 ) : SubscribeToAgentSubscriber => {
@@ -772,8 +755,7 @@ export class CopilotKitCore {
772755 return guarded ;
773756 } ;
774757
775- // Warn about unsupported keys before branching so both throttled and
776- // unthrottled paths get the same diagnostic for JS / `as any` consumers.
758+ // Warn about unsupported keys so JS / `as any` consumers get diagnostics.
777759 for ( const key of Object . keys ( subscriber ) ) {
778760 if (
779761 typeof ( subscriber as Record < string , unknown > ) [ key ] === "function" &&
@@ -797,18 +779,16 @@ export class CopilotKitCore {
797779 }
798780 }
799781
782+ // No throttle — guard callbacks and subscribe directly.
800783 if ( effectiveMs <= 0 ) {
801784 const subscription = agent . subscribe ( guardAll ( subscriber ) ) ;
802785 return { unsubscribe : ( ) => subscription . unsubscribe ( ) } ;
803786 }
804787
805- // Build a wrapper that throttles onMessagesChanged and onStateChanged
806- // behind a shared leading+trailing gate, so rapid bursts of messages
807- // and/or state changes coalesce into fewer consumer notifications.
808- // All other callbacks are guarded but fire immediately.
788+ // Throttled path: lifecycle callbacks fire immediately; onMessagesChanged
789+ // and onStateChanged share a single Throttler that flushes the latest
790+ // params for both channels.
809791 let active = true ;
810- let timerId : ReturnType < typeof setTimeout > | null = null ;
811- let throttleActive = false ;
812792 let latestMessagesParams :
813793 | Parameters <
814794 NonNullable < SubscribeToAgentSubscriber [ "onMessagesChanged" ] >
@@ -824,43 +804,20 @@ export class CopilotKitCore {
824804 latestMessagesParams = null ;
825805 safeCall ( "onMessagesChanged" , subscriber . onMessagesChanged , params ) ;
826806 }
827- // Re-check `active` — the onMessagesChanged callback above might have
828- // called unsubscribe() synchronously, which sets active=false to prevent
829- // the sibling onStateChanged from firing after teardown. Note: if
830- // onMessagesChanged is async and calls unsubscribe() after an await,
831- // this check will not prevent onStateChanged from firing on this flush.
832807 if ( active && subscriber . onStateChanged && latestStateParams ) {
833808 const params = latestStateParams ;
834809 latestStateParams = null ;
835810 safeCall ( "onStateChanged" , subscriber . onStateChanged , params ) ;
836811 }
837812 } ;
838813
839- const scheduleOrFlush = ( ) => {
840- if ( ! active ) return ;
841- if ( ! throttleActive ) {
842- // Leading edge — fire immediately and start the throttle window
843- throttleActive = true ;
844- flushPending ( ) ;
845- timerId = setTimeout ( function trailingEdge ( ) {
846- timerId = null ;
847- if (
848- active &&
849- ( latestMessagesParams !== null || latestStateParams !== null )
850- ) {
851- flushPending ( ) ;
852- timerId = setTimeout ( trailingEdge , effectiveMs ) ;
853- } else {
854- throttleActive = false ;
855- }
856- } , effectiveMs ) ;
857- }
858- // else: within the window, pending params are already set by the caller
859- } ;
814+ const throttler = new Throttler ( flushPending , {
815+ wait : effectiveMs ,
816+ leading : true ,
817+ trailing : true ,
818+ } ) ;
860819
861- // Only wrap lifecycle callbacks with guardAll — onMessagesChanged and
862- // onStateChanged are handled by the throttle path which calls safeCall
863- // directly on the original subscriber callbacks when flushing.
820+ // Lifecycle callbacks are guarded but never throttled.
864821 const lifecycleOnly : SubscribeToAgentSubscriber = { } ;
865822 if ( subscriber . onRunInitialized )
866823 lifecycleOnly . onRunInitialized = subscriber . onRunInitialized ;
@@ -875,14 +832,14 @@ export class CopilotKitCore {
875832 if ( subscriber . onMessagesChanged ) {
876833 wrappedSubscriber . onMessagesChanged = ( params ) => {
877834 latestMessagesParams = params ;
878- scheduleOrFlush ( ) ;
835+ throttler . maybeExecute ( ) ;
879836 } ;
880837 }
881838
882839 if ( subscriber . onStateChanged ) {
883840 wrappedSubscriber . onStateChanged = ( params ) => {
884841 latestStateParams = params ;
885- scheduleOrFlush ( ) ;
842+ throttler . maybeExecute ( ) ;
886843 } ;
887844 }
888845
@@ -891,10 +848,7 @@ export class CopilotKitCore {
891848 return {
892849 unsubscribe : ( ) => {
893850 active = false ;
894- if ( timerId !== null ) {
895- clearTimeout ( timerId ) ;
896- timerId = null ;
897- }
851+ throttler . cancel ( ) ;
898852 subscription . unsubscribe ( ) ;
899853 } ,
900854 } ;
0 commit comments