Skip to content

Commit 54adb83

Browse files
committed
feat: rate limit v1
1 parent 0c24236 commit 54adb83

3 files changed

Lines changed: 316 additions & 0 deletions

File tree

packages/core/src/agent/agent.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import type { BaseRetriever } from "../retriever/retriever";
4848
import type { Tool, Toolkit } from "../tool";
4949
import { createTool } from "../tool";
5050
import { ToolManager } from "../tool/manager";
51+
import { type TrafficRequestMetadata, getTrafficController } from "../traffic/traffic-controller";
5152
import { randomUUID } from "../utils/id";
5253
import { convertModelMessagesToUIMessages } from "../utils/message-converter";
5354
import { NodeType, createNodeId } from "../utils/node-utils";
@@ -443,6 +444,17 @@ export class Agent {
443444
async generateText(
444445
input: string | UIMessage[] | BaseMessage[],
445446
options?: GenerateTextOptions,
447+
): Promise<GenerateTextResultWithContext> {
448+
const controller = getTrafficController({ logger: this.logger }); // Use shared controller so all agent calls flow through central queue/metrics
449+
return controller.handleText({
450+
metadata: this.buildTrafficMetadata(), // Pass model/provider info for future rate limiting keys
451+
execute: () => this.executeGenerateText(input, options), // Defer actual execution so controller can schedule it
452+
});
453+
}
454+
455+
private async executeGenerateText(
456+
input: string | UIMessage[] | BaseMessage[],
457+
options?: GenerateTextOptions,
446458
): Promise<GenerateTextResultWithContext> {
447459
const startTime = Date.now();
448460
const oc = this.createOperationContext(input, options);
@@ -770,6 +782,17 @@ export class Agent {
770782
async streamText(
771783
input: string | UIMessage[] | BaseMessage[],
772784
options?: StreamTextOptions,
785+
): Promise<StreamTextResultWithContext> {
786+
const controller = getTrafficController({ logger: this.logger }); // Same controller handles streaming to keep ordering/backpressure consistent
787+
return controller.handleStream({
788+
metadata: this.buildTrafficMetadata(), // Include identifiers to support per-provider/model policies later
789+
execute: () => this.executeStreamText(input, options), // Actual streaming work happens after the controller dequeues us
790+
});
791+
}
792+
793+
private async executeStreamText(
794+
input: string | UIMessage[] | BaseMessage[],
795+
options?: StreamTextOptions,
773796
): Promise<StreamTextResultWithContext> {
774797
const startTime = Date.now();
775798
const oc = this.createOperationContext(input, options);
@@ -3790,6 +3813,24 @@ export class Agent {
37903813
return this.subAgentManager.calculateMaxSteps(this.maxSteps);
37913814
}
37923815

3816+
private buildTrafficMetadata(): TrafficRequestMetadata {
3817+
// Capture provider if the model object exposes it; fallback is undefined to avoid bad assumptions
3818+
const provider =
3819+
typeof this.model === "object" &&
3820+
this.model !== null &&
3821+
"provider" in this.model &&
3822+
typeof (this.model as any).provider === "string"
3823+
? ((this.model as any).provider as string)
3824+
: undefined;
3825+
3826+
return {
3827+
agentId: this.id, // Identify which agent issued the request
3828+
agentName: this.name, // Human-readable label for logs/metrics
3829+
model: this.getModelName(), // Used for future capacity policies
3830+
provider, // Allows per-provider throttling later
3831+
};
3832+
}
3833+
37933834
/**
37943835
* Get the model name
37953836
*/

packages/core/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ export type {
2121
WorkflowTimelineEvent,
2222
RegisteredWorkflow,
2323
} from "./workflow";
24+
export {
25+
// Surface traffic controller so downstream consumers can route agent calls through the shared scheduler
26+
TrafficController,
27+
getTrafficController,
28+
type TrafficRequest,
29+
type TrafficRequestMetadata,
30+
type TrafficRequestType,
31+
} from "./traffic/traffic-controller";
2432
// Export new Agent from agent.ts
2533
export {
2634
Agent,
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import type { Logger } from "@voltagent/internal";
2+
import { randomUUID } from "../utils/id";
3+
4+
type Scheduler = (callback: () => void) => void;
5+
type BivariantHandler<TArgs extends unknown[]> = {
6+
bivarianceHack(...args: TArgs): void;
7+
}["bivarianceHack"];
8+
9+
interface RateLimitBucket {
10+
tokens: number;
11+
capacity: number;
12+
refillPerMs: number;
13+
lastRefill: number;
14+
}
15+
16+
export interface RateLimitOptions {
17+
capacity: number;
18+
refillPerSecond: number;
19+
}
20+
21+
export type TrafficRequestType = "text" | "stream";
22+
23+
export interface TrafficRequestMetadata {
24+
agentId?: string;
25+
agentName?: string;
26+
model?: string;
27+
provider?: string;
28+
}
29+
30+
export interface TrafficRequest<TResponse> {
31+
metadata?: TrafficRequestMetadata;
32+
execute: () => Promise<TResponse>;
33+
}
34+
35+
interface QueuedRequest<TResponse = unknown> {
36+
id: string;
37+
type: TrafficRequestType;
38+
request: TrafficRequest<TResponse>;
39+
resolve: BivariantHandler<[TResponse | PromiseLike<TResponse>]>;
40+
reject: BivariantHandler<[reason?: unknown]>;
41+
}
42+
43+
export interface TrafficControllerOptions {
44+
logger?: Logger;
45+
maxConcurrent?: number;
46+
rateLimit?: RateLimitOptions;
47+
}
48+
49+
// Centralized traffic controller responsible for scheduling LLM calls.
50+
// Provides a FIFO queue with a non-blocking scheduler and entrypoints
51+
// for text and stream traffic.
52+
export class TrafficController {
53+
private readonly scheduler: Scheduler;
54+
private readonly maxConcurrent: number;
55+
private readonly rateLimit?: { capacity: number; refillPerMs: number };
56+
private readonly rateLimitBuckets = new Map<string, RateLimitBucket>();
57+
private logger?: Logger;
58+
private queue: QueuedRequest[] = [];
59+
private activeCount = 0;
60+
private drainScheduled = false;
61+
private refillTimeout?: ReturnType<typeof setTimeout>;
62+
63+
constructor(options: TrafficControllerOptions = {}) {
64+
this.logger = options.logger; // Allow caller to plug in their logger for observability
65+
this.maxConcurrent = options.maxConcurrent ?? Number.POSITIVE_INFINITY; // Concurrency guard; defaults to no cap for now
66+
this.scheduler = this.createScheduler(); // Select scheduler once so the rest of the code can stay simple
67+
if (
68+
options.rateLimit &&
69+
options.rateLimit.capacity > 0 &&
70+
options.rateLimit.refillPerSecond > 0
71+
) {
72+
this.rateLimit = {
73+
capacity: options.rateLimit.capacity,
74+
refillPerMs: options.rateLimit.refillPerSecond / 1000, // Convert to ms once so the math later stays simple
75+
};
76+
}
77+
}
78+
79+
setLogger(logger?: Logger): void {
80+
this.logger = logger; // Update logger when the singleton is reused across agents
81+
}
82+
83+
handleText<TResponse>(request: TrafficRequest<TResponse>): Promise<TResponse> {
84+
// Route text generation requests into the queue so all LLM calls share the same scheduler
85+
return this.enqueue("text", request);
86+
}
87+
88+
handleStream<TResponse>(request: TrafficRequest<TResponse>): Promise<TResponse> {
89+
// Route streaming requests through the same queue to preserve ordering/backpressure rules
90+
return this.enqueue("stream", request);
91+
}
92+
93+
private createScheduler(): Scheduler {
94+
// Prefer queueMicrotask to keep the drain loop snappy without starving the event loop
95+
if (typeof queueMicrotask === "function") {
96+
return queueMicrotask;
97+
}
98+
99+
return (callback: () => void) => setTimeout(callback, 0);
100+
}
101+
102+
private enqueue<TResponse>(
103+
type: TrafficRequestType,
104+
request: TrafficRequest<TResponse>,
105+
): Promise<TResponse> {
106+
// Each request gets a promise so callers can await their own result
107+
return new Promise<TResponse>((resolve, reject) => {
108+
// Collect the work item and metadata
109+
this.queue.push({
110+
id: randomUUID(),
111+
type,
112+
request,
113+
resolve,
114+
reject,
115+
});
116+
117+
// Emit trace-friendly breadcrumb for observability
118+
this.logger?.debug?.("[TrafficController] enqueued", {
119+
type,
120+
queueSize: this.queue.length,
121+
metadata: request.metadata,
122+
});
123+
124+
// Kick the drain loop to start handling work
125+
this.scheduleDrain();
126+
});
127+
}
128+
129+
private scheduleDrain(): void {
130+
if (this.drainScheduled) {
131+
return;
132+
}
133+
134+
this.drainScheduled = true; // Prevent redundant scheduling when many requests arrive at once
135+
this.scheduler(() => {
136+
this.drainScheduled = false;
137+
this.drainQueue(); // Drain asynchronously so we never block the caller's tick
138+
});
139+
}
140+
141+
private drainQueue(): void {
142+
// Pull as many items as we can until we hit capacity or rate limits
143+
while (this.queue.length > 0) {
144+
const next = this.queue[0]; // Peek without removing so we only dequeue when we can process
145+
if (!next) {
146+
break;
147+
}
148+
if (!this.canProcess(next)) {
149+
return; // Stop early; drain will be rescheduled once capacity frees up
150+
}
151+
152+
this.queue.shift(); // Remove after we've confirmed we can process
153+
this.activeCount++; // Track in-flight work to enforce concurrency guard
154+
155+
this.logger?.debug?.("[TrafficController] dispatch", {
156+
type: next.type,
157+
queueSize: this.queue.length,
158+
active: this.activeCount,
159+
metadata: next.request.metadata,
160+
});
161+
162+
void this.runRequest(next); // Fire off processing without blocking the loop
163+
}
164+
}
165+
166+
private canProcess(next: QueuedRequest): boolean {
167+
if (this.activeCount >= this.maxConcurrent) {
168+
return false;
169+
}
170+
171+
if (!this.rateLimit) {
172+
return true; // No rate limit configured
173+
}
174+
175+
// Token bucket guard: only proceed when a token is available
176+
const bucket = this.getRateLimitBucket(next.request.metadata);
177+
if (bucket.tokens < 1) {
178+
this.scheduleRefill(); // Ensure we retry as soon as tokens are replenished
179+
return false;
180+
}
181+
182+
bucket.tokens -= 1; // Consume a token for this dispatch
183+
return true;
184+
}
185+
186+
private getRateLimitBucket(metadata?: TrafficRequestMetadata): RateLimitBucket {
187+
const rateLimit = this.rateLimit;
188+
if (!rateLimit) {
189+
throw new Error("Rate limit bucket requested without rate limit configuration");
190+
}
191+
192+
const key = this.buildRateLimitKey(metadata); // Group by provider+model so they share limits
193+
const now = Date.now(); // Snapshot time once to avoid drift within this method
194+
let bucket = this.rateLimitBuckets.get(key); // Reuse the bucket if it already exists
195+
196+
if (!bucket) {
197+
// First request for this key: create a fresh bucket at full capacity
198+
bucket = {
199+
tokens: rateLimit.capacity,
200+
capacity: rateLimit.capacity,
201+
refillPerMs: rateLimit.refillPerMs,
202+
lastRefill: now,
203+
};
204+
this.rateLimitBuckets.set(key, bucket);
205+
return bucket;
206+
}
207+
208+
const elapsedMs = Math.max(0, now - bucket.lastRefill);
209+
if (elapsedMs > 0 && bucket.tokens < bucket.capacity) {
210+
const refilled = elapsedMs * bucket.refillPerMs; // Refill based on elapsed time
211+
bucket.tokens = Math.min(bucket.capacity, bucket.tokens + refilled); // Cap at bucket capacity
212+
bucket.lastRefill = now; // Mark refill time for the next calculation
213+
}
214+
215+
return bucket;
216+
}
217+
218+
private buildRateLimitKey(metadata?: TrafficRequestMetadata): string {
219+
const provider = metadata?.provider ?? "default-provider";
220+
const model = metadata?.model ?? "default-model";
221+
return `${provider}::${model}`;
222+
}
223+
224+
private scheduleRefill(): void {
225+
if (this.refillTimeout || !this.rateLimit) {
226+
return;
227+
}
228+
229+
const delayMs = Math.max(1, Math.ceil(1 / this.rateLimit.refillPerMs)); // Wait long enough for at least one token
230+
this.refillTimeout = setTimeout(() => {
231+
this.refillTimeout = undefined; // Allow future refills to be scheduled
232+
this.scheduleDrain(); // Try draining again now that tokens should exist
233+
}, delayMs);
234+
}
235+
236+
private async runRequest<TResponse>(item: QueuedRequest<TResponse>): Promise<void> {
237+
try {
238+
const result = await item.request.execute(); // Execute the user's operation
239+
item.resolve(result); // Deliver successful result back to the waiting caller
240+
} catch (error) {
241+
item.reject(error); // Surface failures to the caller
242+
} finally {
243+
this.activeCount = Math.max(0, this.activeCount - 1); // Ensure counter never underflows
244+
this.scheduleDrain(); // Immediately try to pull the next request
245+
}
246+
}
247+
}
248+
249+
declare global {
250+
// eslint-disable-next-line no-var
251+
var ___voltagent_traffic_controller: TrafficController | undefined;
252+
}
253+
254+
/**
255+
* Retrieve the shared traffic controller instance.
256+
*/
257+
export function getTrafficController(options?: TrafficControllerOptions): TrafficController {
258+
if (!globalThis.___voltagent_traffic_controller) {
259+
// Create a singleton controller so all agents share the same queue/scheduling behavior
260+
globalThis.___voltagent_traffic_controller = new TrafficController(options);
261+
} else if (options?.logger) {
262+
// Update logger when caller provides a new one, keeping the singleton instance alive
263+
globalThis.___voltagent_traffic_controller.setLogger(options.logger);
264+
}
265+
266+
return globalThis.___voltagent_traffic_controller;
267+
}

0 commit comments

Comments
 (0)