Skip to content

Commit 35e0c41

Browse files
authored
Add high limit to bulk message endpoint (#83)
Replaces the agentex-sdk's implementation of subscribeTaskState with a custom, near identical version that includes a 1000 limit on the messages endpoint. This is intended to be a stop gap solution to an issue where only the last 50 messages appear in the UI. The long term fix will be to add an order_by field to our bulk get endpoints and changing the typescript sdk to order the messages by desc. This PR will be effectively reverted once that is completed.
1 parent 7a0be61 commit 35e0c41

2 files changed

Lines changed: 277 additions & 1 deletion

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { IDeltaAccumulator } from 'agentex/lib';
2+
import { aggregateMessageEvents } from 'agentex/lib/aggregate-message-events';
3+
import { compareDateStrings } from 'agentex/lib/compare-date-strings';
4+
import { taskStreamEventGenerator } from 'agentex/lib/task-stream-event-generator';
5+
6+
import type { Agentex } from 'agentex';
7+
import type { Agent, Task, TaskMessage } from 'agentex/resources';
8+
9+
/**
10+
*
11+
* TODO(msun): DELETE THIS FILE AFTER IMPLEMENTING ORDER BY IN MESSAGE LIST ENDPOINT
12+
*
13+
* This file is a near identical copy of the resources/messages/messages.ts file in the agentex SDK,
14+
* with the exception of line 144, where we add a high default limit to the message list endpoint
15+
* to avoid the default 50 message limit that is set in the agentex SDK.
16+
* The better long term solution is to implement the order by in the message list endpoint and add
17+
* infinite scrolling to the task messages component.
18+
*/
19+
20+
type TaskIdentifier = { taskID: string };
21+
22+
/**
23+
* This is useful to subscribe your client application state with the Agentex API.
24+
*
25+
* Treat all arguments from callbacks as readonly! If you'd like to modify the state in your application, create a copy.
26+
*/
27+
export interface ITaskEventListener {
28+
onTaskChange(task: Readonly<Task>): void;
29+
onAgentsChange(agents: ReadonlyArray<Readonly<Agent>>): void;
30+
onMessagesChange(messages: ReadonlyArray<Readonly<TaskMessage>>): void;
31+
onError(errorMessage: string): void;
32+
onStreamStatusChange(
33+
status: 'connected' | 'reconnecting' | 'disconnected'
34+
): void;
35+
}
36+
37+
/**
38+
* This reconnect policy resets whenever a successful connection is made to the task stream.
39+
*
40+
* e.g. Successful connection -> error (count 1) -> error (count 2) -> successful connection (reset) -> error (count 1)
41+
*/
42+
type TaskStreamReconnectPolicy = {
43+
/** immediately reconnect until we see this many errors */
44+
startBackoffOnErrorNum?: number;
45+
/** reconnect up to this many times, then throw the error. < 0 indicates no limit */
46+
maxContinuousErrors?: number;
47+
/** the first backoff is this many milliseconds */
48+
exponentialBackoffMultiplierMs?: number;
49+
/** then backoff time grows by this factor exponentially */
50+
exponentialBackoffBase?: number;
51+
};
52+
53+
type AbortedReturn = {
54+
aborted: true;
55+
taskStreamReconnectLimitReached?: undefined;
56+
error?: undefined;
57+
};
58+
59+
type ErrorReturn = {
60+
taskStreamReconnectLimitReached: boolean;
61+
error: unknown;
62+
aborted?: undefined;
63+
};
64+
65+
/**
66+
* Subscribes to real-time task state changes, including task updates, agent changes, and message events.
67+
* Automatically handles reconnection with exponential backoff on connection failures.
68+
*
69+
* TODO: add cursor + limit to messages so you don't have to load all messages.
70+
*
71+
* @param client - The Agentex client instance for API communication
72+
* @param taskIdentifier - Right now this is just the taskID, but we should allow taskName later.
73+
* @param eventListener - Get the latest task state through these callbacks.
74+
* @param options.signal - AbortSignal to cancel the subscription. This is necessary to prevent resource leaks!
75+
* @param options.taskStreamReconnectPolicy - Override the default reconnect policy. Defaults to `{
76+
* startBackoffOnErrorNum: 2,
77+
* maxContinuousErrors: 8,
78+
* exponentialBackoffMultiplierMs: 500,
79+
* exponentialBackoffBase: 2
80+
* }`
81+
* @returns Promise that resolves with `{ aborted: true }` if the abort signal was triggered,
82+
* otherwise `{ taskStreamReconnectLimitReached: boolean; error: unknown }`
83+
* where `taskStreamReconnectLimitReached` indicates if `taskStreamReconnectPolicy.maxContinuousErrors` reached and error was the last caught error.
84+
* This promise will never reject.
85+
*/
86+
export async function subscribeTaskState(
87+
client: Agentex,
88+
taskIdentifier: TaskIdentifier,
89+
eventListener: ITaskEventListener,
90+
options?: {
91+
signal?: AbortSignal | undefined | null;
92+
taskStreamReconnectPolicy?: TaskStreamReconnectPolicy;
93+
}
94+
): Promise<AbortedReturn | ErrorReturn> {
95+
const { taskID } = taskIdentifier;
96+
const { signal, taskStreamReconnectPolicy } = options ?? {};
97+
98+
const startBackoffOnErrorNum =
99+
taskStreamReconnectPolicy?.startBackoffOnErrorNum ?? 2;
100+
const continuousErrorRetryLimit =
101+
taskStreamReconnectPolicy?.maxContinuousErrors ?? 8;
102+
const exponentialBackoffMultiplierMS =
103+
taskStreamReconnectPolicy?.exponentialBackoffMultiplierMs ?? 500;
104+
const exponentialBackoffBase =
105+
taskStreamReconnectPolicy?.exponentialBackoffBase ?? 2;
106+
107+
// current subscription state
108+
let messages: TaskMessage[] | null = null;
109+
let deltaAccumulator: IDeltaAccumulator | null = null;
110+
let continuousAPIErrorCount = 0;
111+
112+
// RETRY WHILE LOOP
113+
while (!signal?.aborted) {
114+
// RETRY WHILE LOOP -> STREAM TRY / CATCH
115+
try {
116+
for await (const taskEvent of taskStreamEventGenerator(
117+
client,
118+
{ taskID },
119+
{ signal }
120+
)) {
121+
if (signal?.aborted) {
122+
return { aborted: true };
123+
}
124+
125+
// RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY / CATCH
126+
try {
127+
switch (taskEvent.type) {
128+
case 'connected':
129+
// RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: connected
130+
continuousAPIErrorCount = 0;
131+
132+
// pause reading from stream until we initialize state
133+
[, , messages] = await Promise.all([
134+
client.tasks.retrieve(taskID, null, { signal }).then(res => {
135+
eventListener.onTaskChange(res);
136+
return res;
137+
}),
138+
client.agents
139+
.list({ task_id: taskID }, { signal })
140+
.then(res => {
141+
eventListener.onAgentsChange(res);
142+
return res;
143+
}),
144+
client.messages
145+
.list({ task_id: taskID, limit: 1000 }, { signal })
146+
.then(res => {
147+
eventListener.onMessagesChange(res);
148+
return res;
149+
}),
150+
]);
151+
152+
// reset delta accumulator on connected event
153+
deltaAccumulator = null;
154+
155+
eventListener.onStreamStatusChange('connected');
156+
// END RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: connected
157+
break;
158+
case 'error':
159+
// RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: error
160+
eventListener.onError(taskEvent.message);
161+
// END RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: error
162+
break;
163+
case 'delta':
164+
case 'done':
165+
case 'full':
166+
case 'start':
167+
// RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: task message update
168+
if (messages === null) {
169+
throw new Error(
170+
`"${taskEvent.type}" event received before "connected" event. This error will result in a retry if we have retries left.`
171+
);
172+
}
173+
174+
const aggregationResult = aggregateMessageEvents(
175+
messages,
176+
deltaAccumulator,
177+
[taskEvent]
178+
);
179+
({ messages, deltaAccumulator } = aggregationResult);
180+
181+
eventListener.onMessagesChange(messages);
182+
183+
// pause listening to stream until all problematic messages are re-fetched
184+
await Promise.all(
185+
Array.from(aggregationResult.refetchMessageIDs.values()).map(
186+
async refetchMessageID => {
187+
if (!refetchMessageID) {
188+
return;
189+
}
190+
const updatedMessage = await client.messages.retrieve(
191+
refetchMessageID,
192+
{ signal }
193+
);
194+
195+
// update message sync
196+
messages =
197+
messages?.map(message =>
198+
message.id === refetchMessageID &&
199+
compareDateStrings(
200+
message.updated_at,
201+
updatedMessage.updated_at
202+
) <= 0
203+
? updatedMessage
204+
: message
205+
) ?? null;
206+
207+
if (messages !== null) {
208+
eventListener.onMessagesChange(messages);
209+
}
210+
}
211+
)
212+
);
213+
// END RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: task message update
214+
break;
215+
default:
216+
// RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: unknown
217+
taskEvent.type satisfies undefined;
218+
console.warn('Unknown task event', taskEvent);
219+
// END RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY -> EVENT: unknown
220+
break;
221+
}
222+
// END RETRY WHILE LOOP -> STREAM TRY -> EXECUTION TRY
223+
} catch (executionError) {
224+
// RETRY WHILE LOOP -> STREAM TRY -> EXECUTION CATCH
225+
if (signal?.aborted) {
226+
return { aborted: true };
227+
}
228+
229+
eventListener.onStreamStatusChange('disconnected');
230+
return {
231+
taskStreamReconnectLimitReached: false,
232+
error: executionError,
233+
};
234+
// END RETRY WHILE LOOP -> STREAM TRY -> EXECUTION CATCH
235+
}
236+
}
237+
} catch (streamError) {
238+
if (signal?.aborted) {
239+
return { aborted: true };
240+
}
241+
242+
// this gets incremented until a successful "connected" event
243+
continuousAPIErrorCount++;
244+
245+
if (
246+
continuousErrorRetryLimit >= 0 &&
247+
continuousAPIErrorCount > continuousErrorRetryLimit
248+
) {
249+
eventListener.onStreamStatusChange('disconnected');
250+
return { taskStreamReconnectLimitReached: true, error: streamError };
251+
}
252+
253+
console.warn(
254+
'subscribeTaskState encountered an error and will attempt to reconnect',
255+
streamError
256+
);
257+
258+
eventListener.onStreamStatusChange('reconnecting');
259+
if (continuousAPIErrorCount >= startBackoffOnErrorNum) {
260+
await new Promise(resolve =>
261+
setTimeout(
262+
resolve,
263+
exponentialBackoffMultiplierMS *
264+
Math.pow(
265+
exponentialBackoffBase,
266+
continuousAPIErrorCount - startBackoffOnErrorNum
267+
)
268+
)
269+
);
270+
}
271+
}
272+
}
273+
274+
return { aborted: true };
275+
}

agentex-ui/hooks/use-task-subscription.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useEffect } from 'react';
22

33
import { InfiniteData, useQueryClient } from '@tanstack/react-query';
4-
import { subscribeTaskState } from 'agentex/lib';
4+
// import { subscribeTaskState } from 'agentex/lib';
55

6+
import { subscribeTaskState } from '@/hooks/custom-subscribe-task-state';
67
import { updateTaskInInfiniteQuery } from '@/hooks/use-create-task';
78
import { taskMessagesKeys } from '@/hooks/use-task-messages';
89
import type { TaskMessagesData } from '@/hooks/use-task-messages';

0 commit comments

Comments
 (0)