@iloveagents/foundry-agent 0.3.1 → 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.
- package/README.md +16 -0
- package/dist/client/agui-runner.d.ts +61 -0
- package/dist/client/agui-runner.js +400 -0
- package/dist/client/runner-events.d.ts +64 -0
- package/dist/client/runner-events.js +1 -0
- package/dist/client/service-fetch.d.ts +112 -0
- package/dist/client/service-fetch.js +244 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/msal/auth-config.d.ts +91 -0
- package/dist/msal/auth-config.js +70 -0
- package/dist/msal/auth-store.d.ts +95 -0
- package/dist/msal/auth-store.js +372 -0
- package/dist/msal/index.d.ts +3 -0
- package/dist/msal/index.js +3 -0
- package/dist/msal/token-fetch.d.ts +16 -0
- package/dist/msal/token-fetch.js +57 -0
- package/dist/store/citation-store.d.ts +42 -0
- package/dist/store/citation-store.js +14 -0
- package/dist/store/link-store.d.ts +29 -0
- package/dist/store/link-store.js +28 -0
- package/dist/store/streaming-status-store.d.ts +27 -0
- package/dist/store/streaming-status-store.js +14 -0
- package/dist/tools/registry.d.ts +48 -0
- package/dist/tools/registry.js +50 -0
- package/package.json +23 -9
- package/AGENTS.md +0 -91
- package/CHANGELOG.md +0 -182
- package/CLAUDE.md +0 -1
- package/src/__tests__/agui-runner.test.ts +0 -404
- package/src/__tests__/auth-store.test.ts +0 -596
- package/src/__tests__/citation-store.test.ts +0 -52
- package/src/__tests__/client-tool-registry.test.ts +0 -84
- package/src/__tests__/link-store.test.ts +0 -48
- package/src/__tests__/service-fetch.test.ts +0 -525
- package/src/__tests__/streaming-status-store.test.ts +0 -22
- package/src/__tests__/token-fetch.test.ts +0 -134
- package/src/client/agui-runner.ts +0 -382
- package/src/client/runner-events.ts +0 -27
- package/src/client/service-fetch.ts +0 -318
- package/src/index.ts +0 -27
- package/src/msal/auth-config.ts +0 -150
- package/src/msal/auth-store.ts +0 -517
- package/src/msal/index.ts +0 -14
- package/src/msal/token-fetch.ts +0 -68
- package/src/store/citation-store.ts +0 -52
- package/src/store/link-store.ts +0 -53
- package/src/store/streaming-status-store.ts +0 -21
- package/src/tools/registry.ts +0 -112
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -8
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @iloveagents/foundry-agent
|
|
2
|
+
|
|
3
|
+
Cross-runtime AG-UI transport for Foundry UI.
|
|
4
|
+
|
|
5
|
+
This package contains the non-React pieces: `AGUIRunner`, service fetch helpers,
|
|
6
|
+
client-tool registry, streaming status store, citation/link stores, and the
|
|
7
|
+
optional MSAL subpath.
|
|
8
|
+
|
|
9
|
+
## Imports
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { AGUIRunner, createServiceFetch } from "@iloveagents/foundry-agent";
|
|
13
|
+
import { authStore, tokenFetch } from "@iloveagents/foundry-agent/msal";
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This package publishes built ESM JavaScript and `.d.ts` declarations.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AG-UI Runner — protocol engine for `@iloveagents/foundry-agent`.
|
|
3
|
+
*
|
|
4
|
+
* Drives the AG-UI SDK's `HttpAgent.runAgent()` with an `AgentSubscriber`,
|
|
5
|
+
* fans the subscriber callbacks out as a normalized `RunnerEvent` stream,
|
|
6
|
+
* and re-issues the run with appended assistant + tool messages whenever a
|
|
7
|
+
* client-side tool produces a result.
|
|
8
|
+
*
|
|
9
|
+
* Framework-agnostic — no React, no `@assistant-ui/*`. The assistant-ui shim
|
|
10
|
+
* in `@iloveagents/foundry-web-ui` (and future Outlook / Teams / native shells)
|
|
11
|
+
* translate `RunnerEvent`s into their own model.
|
|
12
|
+
*
|
|
13
|
+
* @see https://docs.ag-ui.com/sdk/js/client/http-agent
|
|
14
|
+
* @see https://docs.ag-ui.com/sdk/js/client/subscriber
|
|
15
|
+
*/
|
|
16
|
+
import type { Context, Message } from "@ag-ui/core";
|
|
17
|
+
import type { ToolRegistry } from "../tools/registry.js";
|
|
18
|
+
import type { RunnerEvent } from "./runner-events.js";
|
|
19
|
+
export interface AGUIRunnerOptions {
|
|
20
|
+
url?: string;
|
|
21
|
+
threadId?: string;
|
|
22
|
+
/** Optional override for the underlying fetch — useful for auth-attached fetch. */
|
|
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;
|
|
31
|
+
}
|
|
32
|
+
export interface AGUIRunInput {
|
|
33
|
+
/** AG-UI messages — caller is responsible for runtime-specific message conversion. */
|
|
34
|
+
messages: Message[];
|
|
35
|
+
/** Optional state snapshot (e.g. context items) sent to the agent. */
|
|
36
|
+
state?: Record<string, unknown>;
|
|
37
|
+
/** Tool registry consulted for client-side tool dispatch + schemas. */
|
|
38
|
+
registry: ToolRegistry;
|
|
39
|
+
/** Aborts the run — generator returns cleanly when the signal fires. */
|
|
40
|
+
abortSignal?: AbortSignal;
|
|
41
|
+
/** Optional context payload forwarded with every turn (default: empty array). */
|
|
42
|
+
context?: Context[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* AG-UI Runner.
|
|
46
|
+
*
|
|
47
|
+
* Reusable across runs — instantiate once, call `run()` per turn batch.
|
|
48
|
+
* Maintains `threadId` and the `HttpAgent` state snapshot across calls.
|
|
49
|
+
*/
|
|
50
|
+
export declare class AGUIRunner {
|
|
51
|
+
private readonly httpAgent;
|
|
52
|
+
private readonly stallAfterMs;
|
|
53
|
+
constructor(options?: AGUIRunnerOptions);
|
|
54
|
+
get threadId(): string;
|
|
55
|
+
get state(): unknown;
|
|
56
|
+
/**
|
|
57
|
+
* Run a single AG-UI exchange (with multi-turn re-issue when client-side
|
|
58
|
+
* tool results are produced) and yield normalized events.
|
|
59
|
+
*/
|
|
60
|
+
run(input: AGUIRunInput): AsyncGenerator<RunnerEvent>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AG-UI Runner — protocol engine for `@iloveagents/foundry-agent`.
|
|
3
|
+
*
|
|
4
|
+
* Drives the AG-UI SDK's `HttpAgent.runAgent()` with an `AgentSubscriber`,
|
|
5
|
+
* fans the subscriber callbacks out as a normalized `RunnerEvent` stream,
|
|
6
|
+
* and re-issues the run with appended assistant + tool messages whenever a
|
|
7
|
+
* client-side tool produces a result.
|
|
8
|
+
*
|
|
9
|
+
* Framework-agnostic — no React, no `@assistant-ui/*`. The assistant-ui shim
|
|
10
|
+
* in `@iloveagents/foundry-web-ui` (and future Outlook / Teams / native shells)
|
|
11
|
+
* translate `RunnerEvent`s into their own model.
|
|
12
|
+
*
|
|
13
|
+
* @see https://docs.ag-ui.com/sdk/js/client/http-agent
|
|
14
|
+
* @see https://docs.ag-ui.com/sdk/js/client/subscriber
|
|
15
|
+
*/
|
|
16
|
+
import { HttpAgent } from "@ag-ui/client";
|
|
17
|
+
function shouldPreserveAcrossVisibleHistory(message) {
|
|
18
|
+
return message.role === "system" || message.role === "developer" || message.role === "reasoning";
|
|
19
|
+
}
|
|
20
|
+
function mergeProtocolMessagesFromSnapshot(previousMessages, visibleMessages) {
|
|
21
|
+
if (previousMessages.length === 0)
|
|
22
|
+
return [...visibleMessages];
|
|
23
|
+
const visibleById = new Map(visibleMessages.map((message) => [message.id, message]));
|
|
24
|
+
const previousVisibleIds = new Set(previousMessages
|
|
25
|
+
.filter((message) => !shouldPreserveAcrossVisibleHistory(message))
|
|
26
|
+
.map((message) => message.id));
|
|
27
|
+
const isSameVisibleThread = visibleMessages.some((message) => previousVisibleIds.has(message.id));
|
|
28
|
+
if (!isSameVisibleThread)
|
|
29
|
+
return [...visibleMessages];
|
|
30
|
+
const merged = [];
|
|
31
|
+
const emitted = new Set();
|
|
32
|
+
for (const previous of previousMessages) {
|
|
33
|
+
const currentVisible = visibleById.get(previous.id);
|
|
34
|
+
if (currentVisible) {
|
|
35
|
+
merged.push(currentVisible);
|
|
36
|
+
emitted.add(currentVisible.id);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (shouldPreserveAcrossVisibleHistory(previous)) {
|
|
40
|
+
merged.push(previous);
|
|
41
|
+
emitted.add(previous.id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const message of visibleMessages) {
|
|
45
|
+
if (!emitted.has(message.id)) {
|
|
46
|
+
merged.push(message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return merged;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Bridges callback-driven `AgentSubscriber` events into an async-iterable
|
|
53
|
+
* queue the runner's generator drains. Single-producer / single-consumer.
|
|
54
|
+
*/
|
|
55
|
+
class EventQueue {
|
|
56
|
+
constructor() {
|
|
57
|
+
this.buffer = [];
|
|
58
|
+
this.resolve = null;
|
|
59
|
+
this.ended = false;
|
|
60
|
+
this.error = null;
|
|
61
|
+
}
|
|
62
|
+
push(value) {
|
|
63
|
+
this.buffer.push(value);
|
|
64
|
+
this.flush();
|
|
65
|
+
}
|
|
66
|
+
end(error) {
|
|
67
|
+
this.ended = true;
|
|
68
|
+
if (error !== undefined)
|
|
69
|
+
this.error = error;
|
|
70
|
+
this.flush();
|
|
71
|
+
}
|
|
72
|
+
flush() {
|
|
73
|
+
if (this.resolve) {
|
|
74
|
+
const r = this.resolve;
|
|
75
|
+
this.resolve = null;
|
|
76
|
+
r();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async *drain() {
|
|
80
|
+
for (;;) {
|
|
81
|
+
if (this.buffer.length > 0) {
|
|
82
|
+
yield this.buffer.shift();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (this.ended) {
|
|
86
|
+
if (this.error)
|
|
87
|
+
throw this.error;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await new Promise((r) => {
|
|
91
|
+
this.resolve = r;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* AG-UI Runner.
|
|
98
|
+
*
|
|
99
|
+
* Reusable across runs — instantiate once, call `run()` per turn batch.
|
|
100
|
+
* Maintains `threadId` and the `HttpAgent` state snapshot across calls.
|
|
101
|
+
*/
|
|
102
|
+
export class AGUIRunner {
|
|
103
|
+
constructor(options = {}) {
|
|
104
|
+
this.httpAgent = new HttpAgent({
|
|
105
|
+
url: options.url ?? "/api/agent",
|
|
106
|
+
threadId: options.threadId ?? crypto.randomUUID(),
|
|
107
|
+
...(options.fetchFn ? { fetch: options.fetchFn } : {}),
|
|
108
|
+
});
|
|
109
|
+
this.stallAfterMs = options.stallAfterMs ?? 45000;
|
|
110
|
+
}
|
|
111
|
+
get threadId() {
|
|
112
|
+
return this.httpAgent.threadId;
|
|
113
|
+
}
|
|
114
|
+
get state() {
|
|
115
|
+
return this.httpAgent.state;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run a single AG-UI exchange (with multi-turn re-issue when client-side
|
|
119
|
+
* tool results are produced) and yield normalized events.
|
|
120
|
+
*/
|
|
121
|
+
async *run(input) {
|
|
122
|
+
const { messages, state, registry, abortSignal, context } = input;
|
|
123
|
+
const toolCalls = new Map();
|
|
124
|
+
let currentMessages = mergeProtocolMessagesFromSnapshot(this.httpAgent.messages, messages);
|
|
125
|
+
// Replace once at the top with the reconciled AG-UI history. assistant-ui
|
|
126
|
+
// stores only visible chat turns, while AG-UI snapshots may contain
|
|
127
|
+
// model-visible but UI-hidden protocol messages such as system/developer
|
|
128
|
+
// guidance. Preserve those messages across turns when the visible history
|
|
129
|
+
// belongs to the same thread so the next request remains a complete AG-UI
|
|
130
|
+
// conversation without leaking system messages into the rendered chat.
|
|
131
|
+
this.httpAgent.setMessages(currentMessages);
|
|
132
|
+
if (state)
|
|
133
|
+
this.httpAgent.setState(state);
|
|
134
|
+
try {
|
|
135
|
+
for (;;) {
|
|
136
|
+
if (abortSignal?.aborted)
|
|
137
|
+
return;
|
|
138
|
+
const tools = registry.getActiveSchemas();
|
|
139
|
+
const queue = new EventQueue();
|
|
140
|
+
const runId = crypto.randomUUID();
|
|
141
|
+
// Per-turn text accumulator. The model can emit text BEFORE a
|
|
142
|
+
// tool call in the same turn ("Looking up X..." then ui_navigate);
|
|
143
|
+
// dropping it from the follow-up replay changes the next-turn
|
|
144
|
+
// context (no server-side thread state — full history rides each
|
|
145
|
+
// request) and causes inconsistent continuations.
|
|
146
|
+
let turnAssistantText = "";
|
|
147
|
+
// Build the request input now, before runAgent fires — dev tooling
|
|
148
|
+
// consumes this snapshot via the request-sent event. `runId` is
|
|
149
|
+
// pre-generated so the snapshot matches what runAgent emits.
|
|
150
|
+
const runInputSnapshot = {
|
|
151
|
+
threadId: this.httpAgent.threadId,
|
|
152
|
+
runId,
|
|
153
|
+
state: { ...(this.httpAgent.state || {}), ...(state ?? {}) },
|
|
154
|
+
messages: currentMessages,
|
|
155
|
+
tools,
|
|
156
|
+
context: context ?? [],
|
|
157
|
+
};
|
|
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 });
|
|
199
|
+
const subscriber = {
|
|
200
|
+
onRunStartedEvent: () => {
|
|
201
|
+
push({ type: "run-started" });
|
|
202
|
+
push({ type: "streaming-status", status: { status: "thinking" } });
|
|
203
|
+
},
|
|
204
|
+
onTextMessageStartEvent: () => {
|
|
205
|
+
push({ type: "streaming-status", status: { status: "streaming" } });
|
|
206
|
+
},
|
|
207
|
+
onTextMessageContentEvent: ({ event }) => {
|
|
208
|
+
turnAssistantText += event.delta;
|
|
209
|
+
push({ type: "text-delta", delta: event.delta });
|
|
210
|
+
},
|
|
211
|
+
onTextMessageEndEvent: () => {
|
|
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
|
+
});
|
|
225
|
+
},
|
|
226
|
+
onToolCallStartEvent: ({ event }) => {
|
|
227
|
+
const id = event.toolCallId;
|
|
228
|
+
const name = event.toolCallName;
|
|
229
|
+
toolCalls.set(id, { id, name, args: "" });
|
|
230
|
+
push({
|
|
231
|
+
type: "streaming-status",
|
|
232
|
+
status: { status: "calling", toolName: name },
|
|
233
|
+
});
|
|
234
|
+
push({
|
|
235
|
+
type: "tool-call-start",
|
|
236
|
+
id,
|
|
237
|
+
name,
|
|
238
|
+
isClientSide: registry.isRegistered(name),
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
onToolCallArgsEvent: ({ event }) => {
|
|
242
|
+
const tc = toolCalls.get(event.toolCallId);
|
|
243
|
+
if (tc)
|
|
244
|
+
tc.args += event.delta;
|
|
245
|
+
push({ type: "tool-call-args", id: event.toolCallId, delta: event.delta });
|
|
246
|
+
},
|
|
247
|
+
onToolCallEndEvent: async ({ event }) => {
|
|
248
|
+
const tc = toolCalls.get(event.toolCallId);
|
|
249
|
+
if (!tc)
|
|
250
|
+
return;
|
|
251
|
+
// Intercept client-side tools — execute locally and stash the
|
|
252
|
+
// result so the multi-turn loop can replay it on the next turn.
|
|
253
|
+
if (registry.isRegistered(tc.name)) {
|
|
254
|
+
try {
|
|
255
|
+
const resultJson = await registry.executeTool(tc.name, tc.args);
|
|
256
|
+
tc.result = JSON.parse(resultJson);
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
tc.result = {
|
|
260
|
+
error: err instanceof Error ? err.message : "Client-side tool failed",
|
|
261
|
+
};
|
|
262
|
+
tc.isError = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
push({
|
|
266
|
+
type: "tool-call-end",
|
|
267
|
+
id: event.toolCallId,
|
|
268
|
+
args: tc.args,
|
|
269
|
+
result: tc.result,
|
|
270
|
+
isError: tc.isError,
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
onToolCallResultEvent: ({ event }) => {
|
|
274
|
+
const tc = toolCalls.get(event.toolCallId);
|
|
275
|
+
if (!tc)
|
|
276
|
+
return;
|
|
277
|
+
const content = event.content;
|
|
278
|
+
let parsedResult = content;
|
|
279
|
+
let isError = false;
|
|
280
|
+
try {
|
|
281
|
+
const parsed = JSON.parse(content);
|
|
282
|
+
parsedResult = parsed;
|
|
283
|
+
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
|
284
|
+
isError = true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
parsedResult = content;
|
|
289
|
+
}
|
|
290
|
+
tc.result = parsedResult;
|
|
291
|
+
tc.isError = isError;
|
|
292
|
+
push({
|
|
293
|
+
type: "tool-call-result",
|
|
294
|
+
id: event.toolCallId,
|
|
295
|
+
result: parsedResult,
|
|
296
|
+
isError,
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
onRunFinishedEvent: () => {
|
|
300
|
+
sawTerminal = true;
|
|
301
|
+
push({ type: "streaming-status", status: { status: "idle" } });
|
|
302
|
+
push({ type: "run-finished" });
|
|
303
|
+
},
|
|
304
|
+
onRunErrorEvent: ({ event }) => {
|
|
305
|
+
sawTerminal = true;
|
|
306
|
+
push({ type: "streaming-status", status: { status: "idle" } });
|
|
307
|
+
push({ type: "run-error", message: event.message ?? "Run error" });
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
// Wire abort: AG-UI exposes abortRun(); call it when the caller's signal fires.
|
|
311
|
+
const onAbort = () => this.httpAgent.abortRun();
|
|
312
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
313
|
+
// Kick off the run — completion ends the queue. Errors during the
|
|
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).
|
|
322
|
+
const runPromise = this.httpAgent
|
|
323
|
+
.runAgent({ runId, tools, context: context ?? [] }, subscriber)
|
|
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
|
+
});
|
|
345
|
+
try {
|
|
346
|
+
for await (const evt of queue.drain()) {
|
|
347
|
+
yield evt;
|
|
348
|
+
if (abortSignal?.aborted) {
|
|
349
|
+
this.httpAgent.abortRun();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
if (stallTimer !== null)
|
|
356
|
+
clearInterval(stallTimer);
|
|
357
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
358
|
+
await runPromise; // ensure the run task is settled
|
|
359
|
+
}
|
|
360
|
+
// Decide whether to re-issue: any client-side tool that resolved
|
|
361
|
+
// during this turn and hasn't been replayed yet.
|
|
362
|
+
const pendingClientTools = Array.from(toolCalls.values()).filter((tc) => registry.isRegistered(tc.name) && tc.result !== undefined && !tc.followedUp);
|
|
363
|
+
if (pendingClientTools.length === 0)
|
|
364
|
+
break;
|
|
365
|
+
for (const tc of pendingClientTools)
|
|
366
|
+
tc.followedUp = true;
|
|
367
|
+
// Build follow-up messages with the SAME toolCallId the agent emitted.
|
|
368
|
+
// 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]) {
|
|
390
|
+
this.httpAgent.addMessage(m);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
// Normalized terminal status — idempotent if onRunFinishedEvent already
|
|
396
|
+
// fired one. Consumers ignore duplicate idle transitions.
|
|
397
|
+
yield { type: "streaming-status", status: { status: "idle" } };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { StreamingStatus } from "../store/streaming-status-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Normalized event stream emitted by `AGUIRunner.run()`. The runner is
|
|
4
|
+
* framework-agnostic; consumers (e.g. the assistant-ui shim in
|
|
5
|
+
* `@iloveagents/foundry-web-ui`) translate these events into their own runtime model.
|
|
6
|
+
*
|
|
7
|
+
* `request-sent` carries the exact AG-UI request payload — used by host
|
|
8
|
+
* dev-tooling to capture each turn.
|
|
9
|
+
*
|
|
10
|
+
* `turn-started` fires at the top of every iteration of the multi-turn loop
|
|
11
|
+
* (initial request + each client-tool follow-up). Consumers reset their
|
|
12
|
+
* per-turn buffers (e.g. `currentText`) here.
|
|
13
|
+
*/
|
|
14
|
+
export type RunnerEvent = {
|
|
15
|
+
type: "request-sent";
|
|
16
|
+
input: Record<string, unknown>;
|
|
17
|
+
} | {
|
|
18
|
+
type: "turn-started";
|
|
19
|
+
} | {
|
|
20
|
+
type: "run-started";
|
|
21
|
+
} | {
|
|
22
|
+
type: "text-delta";
|
|
23
|
+
delta: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "text-message-end";
|
|
26
|
+
} | {
|
|
27
|
+
type: "tool-call-start";
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
isClientSide: boolean;
|
|
31
|
+
} | {
|
|
32
|
+
type: "tool-call-args";
|
|
33
|
+
id: string;
|
|
34
|
+
delta: string;
|
|
35
|
+
} | {
|
|
36
|
+
type: "tool-call-end";
|
|
37
|
+
id: string;
|
|
38
|
+
args: string;
|
|
39
|
+
result?: unknown;
|
|
40
|
+
isError?: boolean;
|
|
41
|
+
} | {
|
|
42
|
+
type: "tool-call-result";
|
|
43
|
+
id: string;
|
|
44
|
+
result: unknown;
|
|
45
|
+
isError: boolean;
|
|
46
|
+
} | {
|
|
47
|
+
type: "streaming-status";
|
|
48
|
+
status: StreamingStatus;
|
|
49
|
+
} | {
|
|
50
|
+
type: "run-finished";
|
|
51
|
+
} | {
|
|
52
|
+
type: "run-error";
|
|
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;
|
|
64
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-service fetch factory.
|
|
3
|
+
*
|
|
4
|
+
* Returns an authenticated `fetch`-shaped function that:
|
|
5
|
+
* 1. Rewrites local-relative URLs to a configured router base in production
|
|
6
|
+
* (Vite proxy in dev → httpRouteConfigs FQDN in prod).
|
|
7
|
+
* 2. Acquires a Bearer token via the supplied `acquireToken` callback and
|
|
8
|
+
* attaches it as the `Authorization` header.
|
|
9
|
+
*
|
|
10
|
+
* `@iloveagents/foundry-agent` stays auth-mechanism-agnostic — `acquireToken` is
|
|
11
|
+
* supplied by the host (apps/web wires it to MSAL via the `/msal` subpath;
|
|
12
|
+
* future shells could plug in a different token source).
|
|
13
|
+
*/
|
|
14
|
+
export interface ServiceFetchOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Acquire an access token for outgoing requests. Return `null` to skip
|
|
17
|
+
* token attachment (callers without auth — e.g. local dev — pass through
|
|
18
|
+
* to native fetch).
|
|
19
|
+
*
|
|
20
|
+
* The optional ``{ forceRefresh: true }`` argument is passed by the
|
|
21
|
+
* fetch interceptor on a 401 retry — the auth layer should bypass
|
|
22
|
+
* its local token cache and round-trip the token endpoint so we
|
|
23
|
+
* stop re-sending an access token the resource server has already
|
|
24
|
+
* rejected (canonical MSAL.js fix for tab-open-overnight 401 loops).
|
|
25
|
+
*/
|
|
26
|
+
acquireToken: (options?: {
|
|
27
|
+
forceRefresh?: boolean;
|
|
28
|
+
}) => Promise<string | null>;
|
|
29
|
+
/**
|
|
30
|
+
* Force interactive recovery (e.g. ``loginRedirect``) when even a
|
|
31
|
+
* force-refreshed access token gets rejected by the resource server.
|
|
32
|
+
* The fetch interceptor calls this after a SECOND consecutive 401 —
|
|
33
|
+
* at that point we know the silent refresh produced a token the
|
|
34
|
+
* server still won't accept (audience drift, conditional-access
|
|
35
|
+
* re-eval, tenant-policy change), and the only correct UX is to
|
|
36
|
+
* mint a fresh session.
|
|
37
|
+
*
|
|
38
|
+
* Implementations should clear cached auth state and start a redirect
|
|
39
|
+
* to the IdP. They MUST throw rather than return so the fetch caller
|
|
40
|
+
* can stop processing the in-flight request — when this resolves
|
|
41
|
+
* normally the redirect is in flight and the page is about to
|
|
42
|
+
* navigate away.
|
|
43
|
+
*
|
|
44
|
+
* Optional: when omitted, the fetch interceptor lets the second 401
|
|
45
|
+
* propagate as-is. Hosts without an interactive recovery path (e.g.
|
|
46
|
+
* tests, embedded apps) should leave it unset.
|
|
47
|
+
*/
|
|
48
|
+
recoverFromHardAuthFailure?: (reason: unknown) => Promise<never>;
|
|
49
|
+
/**
|
|
50
|
+
* Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
|
|
51
|
+
* Empty / undefined leaves the URL untouched (Vite proxy handles routing
|
|
52
|
+
* in dev).
|
|
53
|
+
*/
|
|
54
|
+
baseUrl?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve `window.location.origin` (or equivalent) for the current runtime.
|
|
57
|
+
* Defaults to a browser-aware lookup; non-browser callers can override.
|
|
58
|
+
*/
|
|
59
|
+
originResolver?: () => string;
|
|
60
|
+
}
|
|
61
|
+
export type ServiceFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
62
|
+
/**
|
|
63
|
+
* Wrapper thrown when ``acquireToken`` rejects inside the fetch
|
|
64
|
+
* interceptor. Lets the outer 401-retry layer distinguish
|
|
65
|
+
* token-acquisition failures (which warrant interactive recovery) from
|
|
66
|
+
* generic ``fetch`` rejections like network drops, aborts, or CORS
|
|
67
|
+
* preflight failures (which do NOT — those would needlessly bounce the
|
|
68
|
+
* user through ``loginRedirect`` on a transient transport error).
|
|
69
|
+
*
|
|
70
|
+
* Exported so hosts that wrap ``serviceFetch`` further can ``instanceof``
|
|
71
|
+
* against the same class without re-declaring it.
|
|
72
|
+
*/
|
|
73
|
+
export declare class TokenAcquisitionError extends Error {
|
|
74
|
+
readonly name = "TokenAcquisitionError";
|
|
75
|
+
readonly cause: unknown;
|
|
76
|
+
constructor(cause: unknown);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build a service-fetch function. Returns a function with the same shape as
|
|
80
|
+
* `fetch` that rewrites URLs + attaches the Bearer token from `acquireToken`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function createServiceFetch(options: ServiceFetchOptions): ServiceFetch;
|
|
83
|
+
/**
|
|
84
|
+
* Error class the fetch interceptor raises (and forwards through
|
|
85
|
+
* ``recoverFromHardAuthFailure``) when a 401 needs interactive
|
|
86
|
+
* recovery. Exposed so the auth-layer recovery can pick up a
|
|
87
|
+
* ``claims`` field if one was extracted from the
|
|
88
|
+
* ``WWW-Authenticate`` header.
|
|
89
|
+
*/
|
|
90
|
+
export declare class AuthInteractionRequiredError extends Error {
|
|
91
|
+
readonly name = "AuthInteractionRequiredError";
|
|
92
|
+
readonly claims?: string;
|
|
93
|
+
constructor(message: string, options?: {
|
|
94
|
+
claims?: string;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Extract the claims challenge string from a resource server's
|
|
99
|
+
* ``WWW-Authenticate: Bearer ... claims="…"`` header. Returns
|
|
100
|
+
* ``undefined`` when the header is missing, malformed, or carries
|
|
101
|
+
* no claims directive.
|
|
102
|
+
*
|
|
103
|
+
* The value is forwarded VERBATIM to MSAL's
|
|
104
|
+
* ``acquireTokenRedirect({ claims })``; MSAL handles the
|
|
105
|
+
* base64-url decode + JSON parse itself. We don't try to validate
|
|
106
|
+
* the inner shape — letting Entra speak for itself avoids drift if
|
|
107
|
+
* the schema evolves.
|
|
108
|
+
*
|
|
109
|
+
* Spec: RFC 6750 ``WWW-Authenticate`` + CAE claims-challenge
|
|
110
|
+
* supplement (Microsoft Identity Platform docs).
|
|
111
|
+
*/
|
|
112
|
+
export declare function parseClaimsChallengeFromWwwAuthenticate(header: string | null | undefined): string | undefined;
|