@iloveagents/foundry-agent 0.4.0 → 0.6.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
- queue.push({ type: "turn-started" });
158
- queue.push({ type: "streaming-status", status: { status: "thinking" } });
159
- queue.push({ type: "request-sent", input: runInputSnapshot });
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
- queue.push({ type: "run-started" });
163
- queue.push({ type: "streaming-status", status: { status: "thinking" } });
201
+ push({ type: "run-started" });
202
+ push({ type: "streaming-status", status: { status: "thinking" } });
164
203
  },
165
204
  onTextMessageStartEvent: () => {
166
- queue.push({ type: "streaming-status", status: { status: "streaming" } });
205
+ push({ type: "streaming-status", status: { status: "streaming" } });
167
206
  },
168
207
  onTextMessageContentEvent: ({ event }) => {
169
208
  turnAssistantText += event.delta;
170
- queue.push({ type: "text-delta", delta: event.delta });
209
+ push({ type: "text-delta", delta: event.delta });
171
210
  },
172
211
  onTextMessageEndEvent: () => {
173
- queue.push({ type: "text-message-end" });
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
- queue.push({
230
+ push({
180
231
  type: "streaming-status",
181
232
  status: { status: "calling", toolName: name },
182
233
  });
183
- queue.push({
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
- queue.push({ type: "tool-call-args", id: event.toolCallId, delta: event.delta });
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
- queue.push({
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
- queue.push({
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
- queue.push({ type: "streaming-status", status: { status: "idle" } });
250
- queue.push({ type: "run-finished" });
300
+ sawTerminal = true;
301
+ push({ type: "streaming-status", status: { status: "idle" } });
302
+ push({ type: "run-finished" });
251
303
  },
252
304
  onRunErrorEvent: ({ event }) => {
253
- queue.push({ type: "streaming-status", status: { status: "idle" } });
254
- queue.push({ type: "run-error", message: event.message ?? "Run error" });
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; transport-level rejections
262
- // (e.g. fetch failure) are propagated through queue.end(err).
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(() => queue.end())
266
- .catch((err) => queue.end(err));
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
- setStreamingStatus: (streamingStatus) => set({ streamingStatus }),
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
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",