Skip to content

Commit b8f4a8f

Browse files
mxmzbclaude
andcommitted
refactor: replace custom throttle with @tanstack/pacer Throttler
Replace the hand-rolled leading+trailing throttle (scheduleOrFlush, setTimeout, throttleActive state machine) with TanStack Pacer's Throttler class. Clean up comments to describe the method generically rather than only in terms of throttling. No public API changes — throttleMs / defaultThrottleMs stay as-is. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 47b46e4 commit b8f4a8f

4 files changed

Lines changed: 166 additions & 523 deletions

File tree

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"dependencies": {
3232
"@ag-ui/client": "0.0.52",
3333
"@copilotkit/shared": "workspace:*",
34+
"@tanstack/pacer": "^0.20.1",
3435
"phoenix": "^1.8.4",
3536
"rxjs": "7.8.1",
3637
"zod-to-json-schema": "^3.24.6"

packages/core/src/__tests__/core-subscribe-to-agent.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -736,14 +736,12 @@ describe("CopilotKitCore.subscribeToAgentWithOptions", () => {
736736
agent.messages = [userMsg("1", "a"), userMsg("2", "b")];
737737
notifyMessagesChanged(agent);
738738

739-
// Trailing edge fires — callback triggers re-entrant notification
740-
vi.advanceTimersByTime(100);
741-
expect(onMessages).toHaveBeenCalledTimes(2);
742-
expect(onMessages.mock.calls[1][0].messages).toHaveLength(2);
743-
744-
// The re-entrant notification should flush on the next trailing edge
739+
// Trailing edge fires — callback triggers re-entrant notification which
740+
// fires immediately as a new leading edge (Pacer considers the previous
741+
// window expired after the trailing fires).
745742
vi.advanceTimersByTime(100);
746743
expect(onMessages).toHaveBeenCalledTimes(3);
744+
expect(onMessages.mock.calls[1][0].messages).toHaveLength(2);
747745
expect(onMessages.mock.calls[2][0].messages).toHaveLength(3);
748746
});
749747

packages/core/src/core/core.ts

Lines changed: 25 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Context,
55
State,
66
} from "@ag-ui/client";
7+
import { Throttler } from "@tanstack/pacer";
78
import {
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

Comments
 (0)