@iloveagents/foundry-agent 0.7.1 → 0.9.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.
@@ -28,6 +28,17 @@ export interface AGUIRunnerOptions {
28
28
  * three missed 15s server heartbeats. Pass `Infinity` to disable.
29
29
  */
30
30
  stallAfterMs?: number;
31
+ /**
32
+ * When a run ends with a client-side tool call that never produced a
33
+ * result, synthesize an error result for it instead of leaving the call
34
+ * dangling. Defaults to `true`.
35
+ *
36
+ * Defence-in-depth for the failure this runner already had once: an
37
+ * unanswered tool call makes the model re-issue the tool and then report
38
+ * that it "didn't complete", while the Responses API rejects a replayed
39
+ * history whose `function_call` has no matching output.
40
+ */
41
+ autoCancelPendingToolCalls?: boolean;
31
42
  }
32
43
  export interface AGUIRunInput {
33
44
  /** AG-UI messages — caller is responsible for runtime-specific message conversion. */
@@ -50,6 +61,7 @@ export interface AGUIRunInput {
50
61
  export declare class AGUIRunner {
51
62
  private readonly httpAgent;
52
63
  private readonly stallAfterMs;
64
+ private readonly autoCancelPendingToolCalls;
53
65
  constructor(options?: AGUIRunnerOptions);
54
66
  get threadId(): string;
55
67
  get state(): unknown;
@@ -14,6 +14,7 @@
14
14
  * @see https://docs.ag-ui.com/sdk/js/client/subscriber
15
15
  */
16
16
  import { HttpAgent } from "@ag-ui/client";
17
+ import { reasoningEffortStore } from "../store/reasoning-effort-store.js";
17
18
  function shouldPreserveAcrossVisibleHistory(message) {
18
19
  return message.role === "system" || message.role === "developer" || message.role === "reasoning";
19
20
  }
@@ -107,6 +108,7 @@ export class AGUIRunner {
107
108
  ...(options.fetchFn ? { fetch: options.fetchFn } : {}),
108
109
  });
109
110
  this.stallAfterMs = options.stallAfterMs ?? 45000;
111
+ this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls ?? true;
110
112
  }
111
113
  get threadId() {
112
114
  return this.httpAgent.threadId;
@@ -147,6 +149,12 @@ export class AGUIRunner {
147
149
  // Build the request input now, before runAgent fires — dev tooling
148
150
  // consumes this snapshot via the request-sent event. `runId` is
149
151
  // pre-generated so the snapshot matches what runAgent emits.
152
+ // The user's reasoning-effort choice rides on every turn as an
153
+ // AG-UI forwardedProp — read at request time so changing it
154
+ // mid-conversation takes effect on the next message. "default"
155
+ // sends nothing so the backend keeps its configured level.
156
+ const chosenEffort = reasoningEffortStore.getState().effort;
157
+ const forwardedProps = chosenEffort === "default" ? {} : { reasoningEffort: chosenEffort };
150
158
  const runInputSnapshot = {
151
159
  threadId: this.httpAgent.threadId,
152
160
  runId,
@@ -154,6 +162,7 @@ export class AGUIRunner {
154
162
  messages: currentMessages,
155
163
  tools,
156
164
  context: context ?? [],
165
+ forwardedProps,
157
166
  };
158
167
  // --- Liveness + protocol-integrity bookkeeping (per turn) ---
159
168
  // AG-UI requires a terminal RUN_FINISHED or RUN_ERROR. A stream that
@@ -211,6 +220,81 @@ export class AGUIRunner {
211
220
  onTextMessageEndEvent: () => {
212
221
  push({ type: "text-message-end" });
213
222
  },
223
+ // Reasoning/thinking. `@ag-ui/client` normalizes the deprecated
224
+ // THINKING_* events onto REASONING_* via its backward-compat
225
+ // middleware, so subscribing here covers both wire dialects.
226
+ onReasoningMessageStartEvent: () => {
227
+ push({ type: "streaming-status", status: { status: "reasoning" } });
228
+ },
229
+ onReasoningMessageContentEvent: ({ event }) => {
230
+ push({ type: "reasoning-delta", delta: event.delta });
231
+ },
232
+ onReasoningEndEvent: () => {
233
+ push({ type: "reasoning-end" });
234
+ // Reasoning ended but the answer hasn't started — back to the
235
+ // generic working state rather than leaving "reasoning" stuck.
236
+ push({ type: "streaming-status", status: { status: "thinking" } });
237
+ },
238
+ onStepStartedEvent: ({ event }) => {
239
+ push({ type: "step-started", name: event.stepName });
240
+ push({
241
+ type: "streaming-status",
242
+ status: { status: lastStatus.status, stepName: event.stepName },
243
+ });
244
+ },
245
+ onStepFinishedEvent: ({ event }) => {
246
+ push({ type: "step-finished", name: event.stepName });
247
+ },
248
+ // Reasoning has two nested boundaries: a BLOCK
249
+ // (REASONING_START…REASONING_END) may contain several MESSAGES
250
+ // (REASONING_MESSAGE_START…REASONING_MESSAGE_END). `reasoning-end`
251
+ // means "the model stopped thinking", so it is emitted only for the
252
+ // block terminal in `onReasoningEndEvent`. Emitting it per message
253
+ // too would fire it repeatedly and let consumers treat reasoning as
254
+ // finished while it is still going.
255
+ onReasoningStartEvent: () => {
256
+ push({ type: "streaming-status", status: { status: "reasoning" } });
257
+ },
258
+ // Server-published agent state. The AG-UI client has already
259
+ // applied the snapshot/patch to `httpAgent.state`; forward the
260
+ // result so consumers don't reach into the client.
261
+ onStateSnapshotEvent: () => {
262
+ push({ type: "agent-state", state: this.httpAgent.state });
263
+ },
264
+ onStateDeltaEvent: ({ event }) => {
265
+ push({
266
+ type: "agent-state",
267
+ state: this.httpAgent.state,
268
+ patch: event.delta,
269
+ });
270
+ },
271
+ // Server-authoritative history (e.g. after backend compaction).
272
+ // Deliberately does NOT touch the in-flight turn: upstream saw
273
+ // mid-run snapshots truncate a streaming answer.
274
+ onMessagesSnapshotEvent: ({ event }) => {
275
+ push({ type: "messages-snapshot", messages: event.messages });
276
+ },
277
+ // Generative-UI surfaces (MCP apps / A2UI), passed through.
278
+ onActivitySnapshotEvent: ({ event }) => {
279
+ push({
280
+ type: "activity",
281
+ messageId: event.messageId,
282
+ activityType: event.activityType,
283
+ content: event.content,
284
+ });
285
+ },
286
+ onActivityDeltaEvent: ({ event }) => {
287
+ push({
288
+ type: "activity",
289
+ messageId: event.messageId,
290
+ activityType: event.activityType,
291
+ patch: event.patch,
292
+ });
293
+ },
294
+ // Provider passthrough — never interpreted here.
295
+ onRawEvent: ({ event }) => {
296
+ push({ type: "raw", event: event.event, source: event.source });
297
+ },
214
298
  onCustomEvent: ({ event }) => {
215
299
  // Server heartbeat: liveness proof during long tool calls /
216
300
  // thinking phases (also keeps intermediary idle-timeouts at bay
@@ -320,7 +404,7 @@ export class AGUIRunner {
320
404
  // the protocol violation here.
321
405
  // 2. Transport-level rejection (fetch failure, TLS reset).
322
406
  const runPromise = this.httpAgent
323
- .runAgent({ runId, tools, context: context ?? [] }, subscriber)
407
+ .runAgent({ runId, tools, context: context ?? [], forwardedProps }, subscriber)
324
408
  .then(() => {
325
409
  if (!sawTerminal && !abortSignal?.aborted) {
326
410
  push({ type: "streaming-status", status: { status: "idle" } });
@@ -357,6 +441,26 @@ export class AGUIRunner {
357
441
  abortSignal?.removeEventListener("abort", onAbort);
358
442
  await runPromise; // ensure the run task is settled
359
443
  }
444
+ // Any client-side tool that was STARTED but never produced a
445
+ // result leaves a `function_call` with no output. Settle it here so
446
+ // the replayed history stays valid and the model isn't left waiting
447
+ // on an answer that will never come.
448
+ if (this.autoCancelPendingToolCalls) {
449
+ for (const tc of toolCalls.values()) {
450
+ if (!registry.isRegistered(tc.name))
451
+ continue;
452
+ if (tc.result !== undefined || tc.followedUp)
453
+ continue;
454
+ tc.result = { error: "Tool call did not complete before the run ended." };
455
+ tc.isError = true;
456
+ yield {
457
+ type: "tool-call-result",
458
+ id: tc.id,
459
+ result: tc.result,
460
+ isError: true,
461
+ };
462
+ }
463
+ }
360
464
  // Decide whether to re-issue: any client-side tool that resolved
361
465
  // during this turn and hasn't been replayed yet.
362
466
  const pendingClientTools = Array.from(toolCalls.values()).filter((tc) => registry.isRegistered(tc.name) && tc.result !== undefined && !tc.followedUp);
@@ -366,29 +470,59 @@ export class AGUIRunner {
366
470
  tc.followedUp = true;
367
471
  // Build follow-up messages with the SAME toolCallId the agent emitted.
368
472
  // Tool results are appended to the agent's message history (AG-UI
369
- // convention — append, never replace). Preserve any pre-tool text
370
- // the model emitted in this turn so the next-turn context matches
371
- // what the user actually saw.
372
- const assistantMsg = {
373
- id: crypto.randomUUID(),
374
- role: "assistant",
375
- content: turnAssistantText,
376
- toolCalls: pendingClientTools.map((tc) => ({
377
- id: tc.id,
378
- type: "function",
379
- function: { name: tc.name, arguments: tc.args },
380
- })),
381
- };
382
- const toolResultMsgs = pendingClientTools.map((tc) => ({
383
- id: crypto.randomUUID(),
384
- role: "tool",
385
- toolCallId: tc.id,
386
- content: typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result),
387
- }));
388
- currentMessages = [...currentMessages, assistantMsg, ...toolResultMsgs];
389
- for (const m of [assistantMsg, ...toolResultMsgs]) {
473
+ // convention — append, never replace).
474
+ //
475
+ // The AG-UI client folds the streamed assistant message — tool calls
476
+ // included into `httpAgent.messages` itself (AbstractAgent applies
477
+ // the event stream to its own history). Appending an unconditional
478
+ // second copy put the SAME toolCallId in the history twice with only
479
+ // one matching tool result, so the model saw an unanswered tool call:
480
+ // it re-issued the tool (a duplicate card in the UI) and then reported
481
+ // that the call "didn't complete", even though the tool had succeeded.
482
+ // Only synthesize what the client hasn't already recorded.
483
+ const recordedToolCallIds = new Set();
484
+ const recordedToolResultIds = new Set();
485
+ for (const message of this.httpAgent.messages) {
486
+ if (message.role === "assistant" && message.toolCalls) {
487
+ for (const call of message.toolCalls)
488
+ recordedToolCallIds.add(call.id);
489
+ }
490
+ else if (message.role === "tool" && message.toolCallId) {
491
+ recordedToolResultIds.add(message.toolCallId);
492
+ }
493
+ }
494
+ const followUpMessages = [];
495
+ // Preserve any pre-tool text the model emitted in this turn so the
496
+ // next-turn context matches what the user actually saw.
497
+ const unrecordedToolCalls = pendingClientTools.filter((tc) => !recordedToolCallIds.has(tc.id));
498
+ if (unrecordedToolCalls.length > 0) {
499
+ followUpMessages.push({
500
+ id: crypto.randomUUID(),
501
+ role: "assistant",
502
+ content: turnAssistantText,
503
+ toolCalls: unrecordedToolCalls.map((tc) => ({
504
+ id: tc.id,
505
+ type: "function",
506
+ function: { name: tc.name, arguments: tc.args },
507
+ })),
508
+ });
509
+ }
510
+ for (const tc of pendingClientTools) {
511
+ if (recordedToolResultIds.has(tc.id))
512
+ continue;
513
+ followUpMessages.push({
514
+ id: crypto.randomUUID(),
515
+ role: "tool",
516
+ toolCallId: tc.id,
517
+ content: typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result),
518
+ });
519
+ }
520
+ for (const m of followUpMessages) {
390
521
  this.httpAgent.addMessage(m);
391
522
  }
523
+ // Re-sync from the client's history: it is the authority on what the
524
+ // next request carries (`prepareRunAgentInput` reads it directly).
525
+ currentMessages = [...this.httpAgent.messages];
392
526
  }
393
527
  }
394
528
  finally {
@@ -46,6 +46,75 @@ export type RunnerEvent = {
46
46
  } | {
47
47
  type: "streaming-status";
48
48
  status: StreamingStatus;
49
+ }
50
+ /**
51
+ * Model reasoning/thinking text (AG-UI `REASONING_*`; the deprecated
52
+ * `THINKING_*` events are normalized onto these by `@ag-ui/client`'s
53
+ * backward-compatibility middleware). Kept separate from `text-delta`
54
+ * because reasoning is not the answer — consumers render it as its own
55
+ * collapsed part, never inline with the reply.
56
+ */
57
+ | {
58
+ type: "reasoning-delta";
59
+ delta: string;
60
+ } | {
61
+ type: "reasoning-end";
62
+ }
63
+ /**
64
+ * Named agent step (AG-UI `STEP_STARTED` / `STEP_FINISHED`). A long
65
+ * agentic run otherwise presents as one opaque "thinking" state for
66
+ * minutes; the step name is what makes the wait legible.
67
+ */
68
+ | {
69
+ type: "step-started";
70
+ name: string;
71
+ } | {
72
+ type: "step-finished";
73
+ name: string;
74
+ }
75
+ /**
76
+ * Agent state published by the server (`STATE_SNAPSHOT` / `STATE_DELTA`).
77
+ * The AG-UI client applies deltas (JSON Patch) to its own state; this
78
+ * event carries the RESULT so consumers can react without reaching into
79
+ * the client. `patch` is present only for deltas, for consumers that want
80
+ * to know what changed rather than just the new value.
81
+ */
82
+ | {
83
+ type: "agent-state";
84
+ state: unknown;
85
+ patch?: unknown[];
86
+ }
87
+ /**
88
+ * Server-authoritative history replacement (`MESSAGES_SNAPSHOT`). Emitted
89
+ * when the backend rewrites the conversation — e.g. after compaction. The
90
+ * runner does NOT interrupt an in-flight message for this: upstream saw
91
+ * mid-run snapshots truncate streaming answers, so consumers should
92
+ * reconcile persisted history and leave the streaming turn alone.
93
+ */
94
+ | {
95
+ type: "messages-snapshot";
96
+ messages: unknown[];
97
+ }
98
+ /**
99
+ * Generative-UI surface (`ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA`) — MCP
100
+ * apps and A2UI. Passed through verbatim; hosts that don't render
101
+ * activities can ignore it.
102
+ */
103
+ | {
104
+ type: "activity";
105
+ messageId: string;
106
+ activityType: string;
107
+ content?: unknown;
108
+ patch?: unknown[];
109
+ }
110
+ /**
111
+ * Provider passthrough (`RAW`). Never interpreted here — hosts use it for
112
+ * provider-specific behaviour and debugging.
113
+ */
114
+ | {
115
+ type: "raw";
116
+ event: unknown;
117
+ source?: string;
49
118
  } | {
50
119
  type: "run-finished";
51
120
  } | {
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { AGUIRunner, type AGUIRunnerOptions, type AGUIRunInput } from "./client/
2
2
  export type { RunnerEvent } from "./client/runner-events.js";
3
3
  export { createServiceFetch, type ServiceFetch, type ServiceFetchOptions, } from "./client/service-fetch.js";
4
4
  export { clientToolRegistry, type ClientToolEntry, type ToolRegistry } from "./tools/registry.js";
5
+ export { agentStateStore } from "./store/agent-state-store.js";
6
+ export { reasoningEffortStore, REASONING_EFFORT_LABELS, type ReasoningEffort, } from "./store/reasoning-effort-store.js";
5
7
  export { streamingStatusStore, type StreamingStatus } from "./store/streaming-status-store.js";
6
8
  export { citationStore, type CitationResult, type CitationHandler, } from "./store/citation-store.js";
7
9
  export { linkStore, resolveLinkHandler, type LinkHandler, type ResolvedLinkHandler, } from "./store/link-store.js";
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export { createServiceFetch, } from "./client/service-fetch.js";
5
5
  // --- Tool registry ---
6
6
  export { clientToolRegistry } from "./tools/registry.js";
7
7
  // --- Stores (vanilla) ---
8
+ export { agentStateStore } from "./store/agent-state-store.js";
9
+ export { reasoningEffortStore, REASONING_EFFORT_LABELS, } from "./store/reasoning-effort-store.js";
8
10
  export { streamingStatusStore } from "./store/streaming-status-store.js";
9
11
  export { citationStore, } from "./store/citation-store.js";
10
12
  export { linkStore, resolveLinkHandler, } from "./store/link-store.js";
@@ -0,0 +1,21 @@
1
+ interface AgentStateState {
2
+ /** Latest server-published agent state, or `null` before the first snapshot. */
3
+ state: unknown;
4
+ /** JSON Patch from the most recent `STATE_DELTA`, if the last update was one. */
5
+ lastPatch: unknown[] | null;
6
+ setAgentState: (state: unknown, patch?: unknown[]) => void;
7
+ reset: () => void;
8
+ }
9
+ /**
10
+ * Server-published agent state (AG-UI `STATE_SNAPSHOT` / `STATE_DELTA`).
11
+ *
12
+ * The AG-UI client already applies snapshots and JSON-Patch deltas to its
13
+ * own internal state, but nothing surfaced that to the UI — a page could
14
+ * only read it by reaching into the transport. This store is the seam:
15
+ * the runner publishes each update, React consumers bind with
16
+ * `useStore(agentStateStore, selector)`.
17
+ *
18
+ * Vanilla store — this package stays zero-React.
19
+ */
20
+ export declare const agentStateStore: import("zustand/vanilla").StoreApi<AgentStateState>;
21
+ export {};
@@ -0,0 +1,18 @@
1
+ import { createStore } from "zustand/vanilla";
2
+ /**
3
+ * Server-published agent state (AG-UI `STATE_SNAPSHOT` / `STATE_DELTA`).
4
+ *
5
+ * The AG-UI client already applies snapshots and JSON-Patch deltas to its
6
+ * own internal state, but nothing surfaced that to the UI — a page could
7
+ * only read it by reaching into the transport. This store is the seam:
8
+ * the runner publishes each update, React consumers bind with
9
+ * `useStore(agentStateStore, selector)`.
10
+ *
11
+ * Vanilla store — this package stays zero-React.
12
+ */
13
+ export const agentStateStore = createStore((set) => ({
14
+ state: null,
15
+ lastPatch: null,
16
+ setAgentState: (state, patch) => set({ state, lastPatch: patch ?? null }),
17
+ reset: () => set({ state: null, lastPatch: null }),
18
+ }));
@@ -0,0 +1,28 @@
1
+ /**
2
+ * How hard the model should think before answering.
3
+ *
4
+ * `"default"` sends nothing and leaves the backend's configured level
5
+ * alone — the user hasn't expressed a preference, so we don't override
6
+ * one. The remaining levels map to the AG-UI/Responses reasoning effort.
7
+ */
8
+ export type ReasoningEffort = "default" | "low" | "medium" | "high" | "xhigh";
9
+ /**
10
+ * Level names follow the convention users already know from other assistants
11
+ * (Instant / Medium / High / Extra High) rather than inventing a private
12
+ * vocabulary. `Auto` is ours: it means "no preference — use whatever the app
13
+ * is configured for", which the protocol enum has no member for.
14
+ */
15
+ export declare const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string>;
16
+ interface ReasoningEffortState {
17
+ effort: ReasoningEffort;
18
+ setEffort: (effort: ReasoningEffort) => void;
19
+ }
20
+ /**
21
+ * The user's chosen reasoning effort, persisted across reloads.
22
+ *
23
+ * Vanilla store (this package is zero-React); the runner reads it when
24
+ * building each request so the choice applies per turn — change it
25
+ * mid-conversation and the next message uses the new level.
26
+ */
27
+ export declare const reasoningEffortStore: import("zustand/vanilla").StoreApi<ReasoningEffortState>;
28
+ export {};
@@ -0,0 +1,39 @@
1
+ import { createStore } from "zustand/vanilla";
2
+ /**
3
+ * Level names follow the convention users already know from other assistants
4
+ * (Instant / Medium / High / Extra High) rather than inventing a private
5
+ * vocabulary. `Auto` is ours: it means "no preference — use whatever the app
6
+ * is configured for", which the protocol enum has no member for.
7
+ */
8
+ export const REASONING_EFFORT_LABELS = {
9
+ default: "Auto",
10
+ low: "Instant",
11
+ medium: "Medium",
12
+ high: "High",
13
+ xhigh: "Extra High",
14
+ };
15
+ const STORAGE_KEY = "foundry:reasoning-effort";
16
+ function readPersisted() {
17
+ if (typeof localStorage === "undefined")
18
+ return "default";
19
+ const raw = localStorage.getItem(STORAGE_KEY);
20
+ return raw === "low" || raw === "medium" || raw === "high" || raw === "xhigh" || raw === "default"
21
+ ? raw
22
+ : "default";
23
+ }
24
+ /**
25
+ * The user's chosen reasoning effort, persisted across reloads.
26
+ *
27
+ * Vanilla store (this package is zero-React); the runner reads it when
28
+ * building each request so the choice applies per turn — change it
29
+ * mid-conversation and the next message uses the new level.
30
+ */
31
+ export const reasoningEffortStore = createStore((set) => ({
32
+ effort: readPersisted(),
33
+ setEffort: (effort) => {
34
+ if (typeof localStorage !== "undefined") {
35
+ localStorage.setItem(STORAGE_KEY, effort);
36
+ }
37
+ set({ effort });
38
+ },
39
+ }));
@@ -1,7 +1,14 @@
1
1
  /** Streaming status emitted by the AG-UI runner. */
2
2
  export interface StreamingStatus {
3
- status: "thinking" | "calling" | "streaming" | "stalled" | "idle";
3
+ status: "thinking" | "reasoning" | "calling" | "streaming" | "stalled" | "idle";
4
4
  toolName?: string;
5
+ /**
6
+ * Name of the agent step currently running, from AG-UI `STEP_STARTED`.
7
+ * Long agentic runs otherwise show one opaque "thinking" state for
8
+ * minutes; the step name is what makes that wait legible. Undefined when
9
+ * the agent doesn't emit step events.
10
+ */
11
+ stepName?: string;
5
12
  }
6
13
  interface StreamingStatusState {
7
14
  streamingStatus: StreamingStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "description": "Cross-runtime AG-UI transport for Foundry UI — AGUIRunner protocol engine, vanilla zustand stores, optional MSAL auth subpath, service-fetch factory. Zero React, zero DOM.",
6
6
  "keywords": [