@iloveagents/foundry-agent 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -21,6 +21,13 @@ export interface AGUIRunnerOptions {
|
|
|
21
21
|
threadId?: string;
|
|
22
22
|
/** Optional override for the underlying fetch — useful for auth-attached fetch. */
|
|
23
23
|
fetchFn?: typeof fetch;
|
|
24
|
+
/**
|
|
25
|
+
* Emit a `streaming-status: stalled` event when no AG-UI event (including
|
|
26
|
+
* server heartbeats) has arrived for this many ms during an active run.
|
|
27
|
+
* The status recovers automatically on the next event. Default 45s —
|
|
28
|
+
* three missed 15s server heartbeats. Pass `Infinity` to disable.
|
|
29
|
+
*/
|
|
30
|
+
stallAfterMs?: number;
|
|
24
31
|
}
|
|
25
32
|
export interface AGUIRunInput {
|
|
26
33
|
/** AG-UI messages — caller is responsible for runtime-specific message conversion. */
|
|
@@ -42,6 +49,7 @@ export interface AGUIRunInput {
|
|
|
42
49
|
*/
|
|
43
50
|
export declare class AGUIRunner {
|
|
44
51
|
private readonly httpAgent;
|
|
52
|
+
private readonly stallAfterMs;
|
|
45
53
|
constructor(options?: AGUIRunnerOptions);
|
|
46
54
|
get threadId(): string;
|
|
47
55
|
get state(): unknown;
|
|
@@ -106,6 +106,7 @@ export class AGUIRunner {
|
|
|
106
106
|
threadId: options.threadId ?? crypto.randomUUID(),
|
|
107
107
|
...(options.fetchFn ? { fetch: options.fetchFn } : {}),
|
|
108
108
|
});
|
|
109
|
+
this.stallAfterMs = options.stallAfterMs ?? 45000;
|
|
109
110
|
}
|
|
110
111
|
get threadId() {
|
|
111
112
|
return this.httpAgent.threadId;
|
|
@@ -154,33 +155,83 @@ export class AGUIRunner {
|
|
|
154
155
|
tools,
|
|
155
156
|
context: context ?? [],
|
|
156
157
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
// --- Liveness + protocol-integrity bookkeeping (per turn) ---
|
|
159
|
+
// AG-UI requires a terminal RUN_FINISHED or RUN_ERROR. A stream that
|
|
160
|
+
// merely closes (proxy idle-timeout, dropped connection, dead
|
|
161
|
+
// replica) would otherwise look like a clean completion and a
|
|
162
|
+
// truncated answer would render as final — the silent-wedge bug.
|
|
163
|
+
let sawTerminal = false;
|
|
164
|
+
let stalled = false;
|
|
165
|
+
let lastStatus = { status: "thinking" };
|
|
166
|
+
let lastEventAt = Date.now();
|
|
167
|
+
const push = (evt) => {
|
|
168
|
+
lastEventAt = Date.now();
|
|
169
|
+
if (evt.type === "streaming-status") {
|
|
170
|
+
if (evt.status.status === "stalled") {
|
|
171
|
+
stalled = true;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
lastStatus = evt.status;
|
|
175
|
+
stalled = false;
|
|
176
|
+
}
|
|
177
|
+
queue.push(evt);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (stalled) {
|
|
181
|
+
// First live signal after a stall — restore the pre-stall phase.
|
|
182
|
+
stalled = false;
|
|
183
|
+
queue.push({ type: "streaming-status", status: lastStatus });
|
|
184
|
+
}
|
|
185
|
+
queue.push(evt);
|
|
186
|
+
};
|
|
187
|
+
const stallTimer = Number.isFinite(this.stallAfterMs)
|
|
188
|
+
? setInterval(() => {
|
|
189
|
+
if (stalled || sawTerminal)
|
|
190
|
+
return;
|
|
191
|
+
if (Date.now() - lastEventAt >= this.stallAfterMs) {
|
|
192
|
+
push({ type: "streaming-status", status: { status: "stalled" } });
|
|
193
|
+
}
|
|
194
|
+
}, Math.min(5000, Math.max(25, Math.floor(this.stallAfterMs / 3))))
|
|
195
|
+
: null;
|
|
196
|
+
push({ type: "turn-started" });
|
|
197
|
+
push({ type: "streaming-status", status: { status: "thinking" } });
|
|
198
|
+
push({ type: "request-sent", input: runInputSnapshot });
|
|
160
199
|
const subscriber = {
|
|
161
200
|
onRunStartedEvent: () => {
|
|
162
|
-
|
|
163
|
-
|
|
201
|
+
push({ type: "run-started" });
|
|
202
|
+
push({ type: "streaming-status", status: { status: "thinking" } });
|
|
164
203
|
},
|
|
165
204
|
onTextMessageStartEvent: () => {
|
|
166
|
-
|
|
205
|
+
push({ type: "streaming-status", status: { status: "streaming" } });
|
|
167
206
|
},
|
|
168
207
|
onTextMessageContentEvent: ({ event }) => {
|
|
169
208
|
turnAssistantText += event.delta;
|
|
170
|
-
|
|
209
|
+
push({ type: "text-delta", delta: event.delta });
|
|
171
210
|
},
|
|
172
211
|
onTextMessageEndEvent: () => {
|
|
173
|
-
|
|
212
|
+
push({ type: "text-message-end" });
|
|
213
|
+
},
|
|
214
|
+
onCustomEvent: ({ event }) => {
|
|
215
|
+
// Server heartbeat: liveness proof during long tool calls /
|
|
216
|
+
// thinking phases (also keeps intermediary idle-timeouts at bay
|
|
217
|
+
// server-side). Not a UI-visible event.
|
|
218
|
+
if (event.name !== "heartbeat")
|
|
219
|
+
return;
|
|
220
|
+
const value = event.value;
|
|
221
|
+
push({
|
|
222
|
+
type: "heartbeat",
|
|
223
|
+
...(typeof value?.elapsedMs === "number" ? { elapsedMs: value.elapsedMs } : {}),
|
|
224
|
+
});
|
|
174
225
|
},
|
|
175
226
|
onToolCallStartEvent: ({ event }) => {
|
|
176
227
|
const id = event.toolCallId;
|
|
177
228
|
const name = event.toolCallName;
|
|
178
229
|
toolCalls.set(id, { id, name, args: "" });
|
|
179
|
-
|
|
230
|
+
push({
|
|
180
231
|
type: "streaming-status",
|
|
181
232
|
status: { status: "calling", toolName: name },
|
|
182
233
|
});
|
|
183
|
-
|
|
234
|
+
push({
|
|
184
235
|
type: "tool-call-start",
|
|
185
236
|
id,
|
|
186
237
|
name,
|
|
@@ -191,7 +242,7 @@ export class AGUIRunner {
|
|
|
191
242
|
const tc = toolCalls.get(event.toolCallId);
|
|
192
243
|
if (tc)
|
|
193
244
|
tc.args += event.delta;
|
|
194
|
-
|
|
245
|
+
push({ type: "tool-call-args", id: event.toolCallId, delta: event.delta });
|
|
195
246
|
},
|
|
196
247
|
onToolCallEndEvent: async ({ event }) => {
|
|
197
248
|
const tc = toolCalls.get(event.toolCallId);
|
|
@@ -211,7 +262,7 @@ export class AGUIRunner {
|
|
|
211
262
|
tc.isError = true;
|
|
212
263
|
}
|
|
213
264
|
}
|
|
214
|
-
|
|
265
|
+
push({
|
|
215
266
|
type: "tool-call-end",
|
|
216
267
|
id: event.toolCallId,
|
|
217
268
|
args: tc.args,
|
|
@@ -238,7 +289,7 @@ export class AGUIRunner {
|
|
|
238
289
|
}
|
|
239
290
|
tc.result = parsedResult;
|
|
240
291
|
tc.isError = isError;
|
|
241
|
-
|
|
292
|
+
push({
|
|
242
293
|
type: "tool-call-result",
|
|
243
294
|
id: event.toolCallId,
|
|
244
295
|
result: parsedResult,
|
|
@@ -246,24 +297,51 @@ export class AGUIRunner {
|
|
|
246
297
|
});
|
|
247
298
|
},
|
|
248
299
|
onRunFinishedEvent: () => {
|
|
249
|
-
|
|
250
|
-
|
|
300
|
+
sawTerminal = true;
|
|
301
|
+
push({ type: "streaming-status", status: { status: "idle" } });
|
|
302
|
+
push({ type: "run-finished" });
|
|
251
303
|
},
|
|
252
304
|
onRunErrorEvent: ({ event }) => {
|
|
253
|
-
|
|
254
|
-
|
|
305
|
+
sawTerminal = true;
|
|
306
|
+
push({ type: "streaming-status", status: { status: "idle" } });
|
|
307
|
+
push({ type: "run-error", message: event.message ?? "Run error" });
|
|
255
308
|
},
|
|
256
309
|
};
|
|
257
310
|
// Wire abort: AG-UI exposes abortRun(); call it when the caller's signal fires.
|
|
258
311
|
const onAbort = () => this.httpAgent.abortRun();
|
|
259
312
|
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
260
313
|
// Kick off the run — completion ends the queue. Errors during the
|
|
261
|
-
// run surface via onRunErrorEvent
|
|
262
|
-
//
|
|
314
|
+
// run surface via onRunErrorEvent. Two failure shapes are normalized
|
|
315
|
+
// onto the same `run-error` surface so every failure renders in chat
|
|
316
|
+
// instead of dying silently:
|
|
317
|
+
// 1. The stream closed WITHOUT a terminal RUN_FINISHED/RUN_ERROR
|
|
318
|
+
// (proxy idle-timeout, dropped connection, dead replica) — the
|
|
319
|
+
// AG-UI client treats that as a clean completion, so we detect
|
|
320
|
+
// the protocol violation here.
|
|
321
|
+
// 2. Transport-level rejection (fetch failure, TLS reset).
|
|
263
322
|
const runPromise = this.httpAgent
|
|
264
323
|
.runAgent({ runId, tools, context: context ?? [] }, subscriber)
|
|
265
|
-
.then(() =>
|
|
266
|
-
|
|
324
|
+
.then(() => {
|
|
325
|
+
if (!sawTerminal && !abortSignal?.aborted) {
|
|
326
|
+
push({ type: "streaming-status", status: { status: "idle" } });
|
|
327
|
+
push({
|
|
328
|
+
type: "run-error",
|
|
329
|
+
message: "Connection to the agent was lost before the response finished — " +
|
|
330
|
+
"the answer may be incomplete. Please retry.",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
queue.end();
|
|
334
|
+
})
|
|
335
|
+
.catch((err) => {
|
|
336
|
+
if (!sawTerminal && !abortSignal?.aborted) {
|
|
337
|
+
push({ type: "streaming-status", status: { status: "idle" } });
|
|
338
|
+
push({
|
|
339
|
+
type: "run-error",
|
|
340
|
+
message: err instanceof Error ? err.message : "The request to the agent failed.",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
queue.end();
|
|
344
|
+
});
|
|
267
345
|
try {
|
|
268
346
|
for await (const evt of queue.drain()) {
|
|
269
347
|
yield evt;
|
|
@@ -274,6 +352,8 @@ export class AGUIRunner {
|
|
|
274
352
|
}
|
|
275
353
|
}
|
|
276
354
|
finally {
|
|
355
|
+
if (stallTimer !== null)
|
|
356
|
+
clearInterval(stallTimer);
|
|
277
357
|
abortSignal?.removeEventListener("abort", onAbort);
|
|
278
358
|
await runPromise; // ensure the run task is settled
|
|
279
359
|
}
|
|
@@ -51,4 +51,14 @@ export type RunnerEvent = {
|
|
|
51
51
|
} | {
|
|
52
52
|
type: "run-error";
|
|
53
53
|
message: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Server liveness signal (AG-UI CUSTOM event named `heartbeat`, emitted by
|
|
57
|
+
* the backend between real events so long tool calls / thinking phases are
|
|
58
|
+
* distinguishable from a dead pipe). Carries the server-side elapsed run
|
|
59
|
+
* time when provided. Consumers refresh their liveness clock; no UI yield.
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
type: "heartbeat";
|
|
63
|
+
elapsedMs?: number;
|
|
54
64
|
};
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/** Streaming status emitted by the AG-UI runner. */
|
|
2
2
|
export interface StreamingStatus {
|
|
3
|
-
status: "thinking" | "calling" | "streaming" | "idle";
|
|
3
|
+
status: "thinking" | "calling" | "streaming" | "stalled" | "idle";
|
|
4
4
|
toolName?: string;
|
|
5
5
|
}
|
|
6
6
|
interface StreamingStatusState {
|
|
7
7
|
streamingStatus: StreamingStatus;
|
|
8
|
+
/**
|
|
9
|
+
* Wall-clock ms of the last signal proving the run is alive — any runner
|
|
10
|
+
* event or a server heartbeat. `null` outside a run. Consumers use it to
|
|
11
|
+
* distinguish "quietly working" from "pipe is dead".
|
|
12
|
+
*/
|
|
13
|
+
lastSignalAt: number | null;
|
|
14
|
+
/** Wall-clock ms when the active run started; `null` when idle. */
|
|
15
|
+
runStartedAt: number | null;
|
|
8
16
|
setStreamingStatus: (status: StreamingStatus) => void;
|
|
17
|
+
/** Record liveness (runner event or server heartbeat arrived). */
|
|
18
|
+
touchSignal: () => void;
|
|
19
|
+
markRunStarted: () => void;
|
|
20
|
+
markRunEnded: () => void;
|
|
9
21
|
}
|
|
10
22
|
/**
|
|
11
23
|
* Vanilla store for streaming status. Use `useStore(streamingStatusStore, ...)`
|
|
@@ -5,5 +5,10 @@ import { createStore } from "zustand/vanilla";
|
|
|
5
5
|
*/
|
|
6
6
|
export const streamingStatusStore = createStore((set) => ({
|
|
7
7
|
streamingStatus: { status: "idle" },
|
|
8
|
-
|
|
8
|
+
lastSignalAt: null,
|
|
9
|
+
runStartedAt: null,
|
|
10
|
+
setStreamingStatus: (streamingStatus) => set({ streamingStatus, lastSignalAt: Date.now() }),
|
|
11
|
+
touchSignal: () => set({ lastSignalAt: Date.now() }),
|
|
12
|
+
markRunStarted: () => set({ runStartedAt: Date.now(), lastSignalAt: Date.now() }),
|
|
13
|
+
markRunEnded: () => set({ runStartedAt: null, lastSignalAt: null }),
|
|
9
14
|
}));
|