@iloveagents/foundry-agent 0.3.0 → 0.4.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.
Files changed (51) hide show
  1. package/README.md +16 -0
  2. package/dist/client/agui-runner.d.ts +53 -0
  3. package/dist/client/agui-runner.js +320 -0
  4. package/dist/client/runner-events.d.ts +54 -0
  5. package/dist/client/runner-events.js +1 -0
  6. package/dist/client/service-fetch.d.ts +112 -0
  7. package/dist/client/service-fetch.js +244 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +10 -0
  10. package/dist/msal/auth-config.d.ts +91 -0
  11. package/dist/msal/auth-config.js +70 -0
  12. package/dist/msal/auth-store.d.ts +95 -0
  13. package/dist/msal/auth-store.js +372 -0
  14. package/dist/msal/index.d.ts +3 -0
  15. package/dist/msal/index.js +3 -0
  16. package/dist/msal/token-fetch.d.ts +16 -0
  17. package/dist/msal/token-fetch.js +57 -0
  18. package/dist/store/citation-store.d.ts +42 -0
  19. package/dist/store/citation-store.js +14 -0
  20. package/dist/store/link-store.d.ts +29 -0
  21. package/dist/store/link-store.js +28 -0
  22. package/dist/store/streaming-status-store.d.ts +15 -0
  23. package/dist/store/streaming-status-store.js +9 -0
  24. package/dist/tools/registry.d.ts +48 -0
  25. package/dist/tools/registry.js +50 -0
  26. package/package.json +23 -9
  27. package/AGENTS.md +0 -91
  28. package/CHANGELOG.md +0 -180
  29. package/CLAUDE.md +0 -1
  30. package/src/__tests__/agui-runner.test.ts +0 -404
  31. package/src/__tests__/auth-store.test.ts +0 -596
  32. package/src/__tests__/citation-store.test.ts +0 -52
  33. package/src/__tests__/client-tool-registry.test.ts +0 -84
  34. package/src/__tests__/link-store.test.ts +0 -48
  35. package/src/__tests__/service-fetch.test.ts +0 -525
  36. package/src/__tests__/streaming-status-store.test.ts +0 -22
  37. package/src/__tests__/token-fetch.test.ts +0 -134
  38. package/src/client/agui-runner.ts +0 -382
  39. package/src/client/runner-events.ts +0 -27
  40. package/src/client/service-fetch.ts +0 -318
  41. package/src/index.ts +0 -27
  42. package/src/msal/auth-config.ts +0 -150
  43. package/src/msal/auth-store.ts +0 -517
  44. package/src/msal/index.ts +0 -14
  45. package/src/msal/token-fetch.ts +0 -68
  46. package/src/store/citation-store.ts +0 -52
  47. package/src/store/link-store.ts +0 -53
  48. package/src/store/streaming-status-store.ts +0 -21
  49. package/src/tools/registry.ts +0 -112
  50. package/tsconfig.json +0 -15
  51. package/vitest.config.ts +0 -8
@@ -1,134 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- const getAccessToken = vi.fn();
4
- const recoverFromHardAuthFailure = vi.fn();
5
-
6
- vi.mock("../msal/auth-store.ts", () => ({
7
- authStore: {
8
- getState: () => ({
9
- getAccessToken,
10
- recoverFromHardAuthFailure,
11
- }),
12
- },
13
- }));
14
-
15
- import { tokenFetch } from "../msal/token-fetch.ts";
16
-
17
- describe("tokenFetch", () => {
18
- beforeEach(() => {
19
- getAccessToken.mockReset();
20
- recoverFromHardAuthFailure.mockReset();
21
- vi.restoreAllMocks();
22
- });
23
-
24
- it("merges Request headers and lets init headers take precedence", async () => {
25
- getAccessToken.mockResolvedValue("test-token");
26
- const fetchSpy = vi
27
- .spyOn(globalThis, "fetch")
28
- .mockResolvedValue(new Response(null, { status: 200 }));
29
-
30
- const request = new Request("https://example.com/api/items", {
31
- headers: {
32
- "Content-Type": "application/json",
33
- "X-Request-Only": "request",
34
- "X-Override": "request",
35
- },
36
- });
37
-
38
- await tokenFetch(request, {
39
- headers: {
40
- "X-Init-Only": "init",
41
- "X-Override": "init",
42
- },
43
- });
44
-
45
- expect(fetchSpy).toHaveBeenCalledTimes(1);
46
- const [, init] = fetchSpy.mock.calls[0]!;
47
- const headers = new Headers(init?.headers);
48
-
49
- expect(headers.get("Authorization")).toBe("Bearer test-token");
50
- expect(headers.get("Content-Type")).toBe("application/json");
51
- expect(headers.get("X-Request-Only")).toBe("request");
52
- expect(headers.get("X-Init-Only")).toBe("init");
53
- expect(headers.get("X-Override")).toBe("init");
54
- });
55
-
56
- it("falls through to native fetch when no token is available", async () => {
57
- getAccessToken.mockResolvedValue(null);
58
- const fetchSpy = vi
59
- .spyOn(globalThis, "fetch")
60
- .mockResolvedValue(new Response(null, { status: 200 }));
61
-
62
- await tokenFetch("https://example.com/api/items");
63
-
64
- expect(fetchSpy).toHaveBeenCalledTimes(1);
65
- const [, init] = fetchSpy.mock.calls[0]!;
66
- expect(init).toBeUndefined();
67
- });
68
-
69
- it("retries once with forceRefresh when first attempt returns 401", async () => {
70
- // Long-lived tab path: cached token is rejected by the API → MSAL is
71
- // asked to round-trip the token endpoint with the refresh token.
72
- getAccessToken
73
- .mockResolvedValueOnce("stale-token")
74
- .mockResolvedValueOnce("fresh-token");
75
- const fetchSpy = vi
76
- .spyOn(globalThis, "fetch")
77
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
78
- .mockResolvedValueOnce(new Response(null, { status: 200 }));
79
-
80
- const res = await tokenFetch("https://example.com/api/items");
81
-
82
- expect(res.status).toBe(200);
83
- expect(fetchSpy).toHaveBeenCalledTimes(2);
84
- expect(getAccessToken).toHaveBeenCalledTimes(2);
85
- expect(getAccessToken).toHaveBeenNthCalledWith(1, "api", undefined);
86
- expect(getAccessToken).toHaveBeenNthCalledWith(2, "api", { forceRefresh: true });
87
-
88
- const firstAuth = new Headers(fetchSpy.mock.calls[0]![1]?.headers).get(
89
- "Authorization",
90
- );
91
- const secondAuth = new Headers(fetchSpy.mock.calls[1]![1]?.headers).get(
92
- "Authorization",
93
- );
94
- expect(firstAuth).toBe("Bearer stale-token");
95
- expect(secondAuth).toBe("Bearer fresh-token");
96
- });
97
-
98
- it("does not retry on non-401 responses", async () => {
99
- getAccessToken.mockResolvedValue("token");
100
- const fetchSpy = vi
101
- .spyOn(globalThis, "fetch")
102
- .mockResolvedValueOnce(new Response(null, { status: 500 }));
103
-
104
- const res = await tokenFetch("https://example.com/api/items");
105
-
106
- expect(res.status).toBe(500);
107
- expect(fetchSpy).toHaveBeenCalledTimes(1);
108
- expect(getAccessToken).toHaveBeenCalledTimes(1);
109
- });
110
-
111
- it("escalates to interactive recovery on a hard 401-then-401 path", async () => {
112
- // Server-policy drift: even the force-refreshed token is rejected.
113
- // The fetch interceptor must call ``recoverFromHardAuthFailure`` so
114
- // the auth layer can kick off ``loginRedirect``. The recovery
115
- // throws once the redirect is in flight; the throw stops the
116
- // calling pipeline.
117
- getAccessToken.mockResolvedValue("token");
118
- recoverFromHardAuthFailure.mockRejectedValue(
119
- new Error("loginRedirect in flight"),
120
- );
121
- const fetchSpy = vi
122
- .spyOn(globalThis, "fetch")
123
- .mockResolvedValueOnce(new Response(null, { status: 401 }))
124
- .mockResolvedValueOnce(new Response(null, { status: 401 }));
125
-
126
- await expect(tokenFetch("https://example.com/api/items")).rejects.toThrow(
127
- "loginRedirect in flight",
128
- );
129
-
130
- expect(fetchSpy).toHaveBeenCalledTimes(2);
131
- expect(getAccessToken).toHaveBeenCalledTimes(2);
132
- expect(recoverFromHardAuthFailure).toHaveBeenCalledTimes(1);
133
- });
134
- });
@@ -1,382 +0,0 @@
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
-
17
- import { HttpAgent, type AgentSubscriber } from "@ag-ui/client";
18
- import type { Context, Message, Tool } from "@ag-ui/core";
19
- import type { ToolRegistry } from "../tools/registry.ts";
20
- import type { RunnerEvent } from "./runner-events.ts";
21
-
22
- interface ToolCallState {
23
- id: string;
24
- name: string;
25
- args: string;
26
- result?: unknown;
27
- isError?: boolean;
28
- followedUp?: boolean;
29
- }
30
-
31
- export interface AGUIRunnerOptions {
32
- url?: string;
33
- threadId?: string;
34
- /** Optional override for the underlying fetch — useful for auth-attached fetch. */
35
- fetchFn?: typeof fetch;
36
- }
37
-
38
- export interface AGUIRunInput {
39
- /** AG-UI messages — caller is responsible for runtime-specific message conversion. */
40
- messages: Message[];
41
- /** Optional state snapshot (e.g. context items) sent to the agent. */
42
- state?: Record<string, unknown>;
43
- /** Tool registry consulted for client-side tool dispatch + schemas. */
44
- registry: ToolRegistry;
45
- /** Aborts the run — generator returns cleanly when the signal fires. */
46
- abortSignal?: AbortSignal;
47
- /** Optional context payload forwarded with every turn (default: empty array). */
48
- context?: Context[];
49
- }
50
-
51
- function shouldPreserveAcrossVisibleHistory(message: Message): boolean {
52
- return message.role === "system" || message.role === "developer" || message.role === "reasoning";
53
- }
54
-
55
- function mergeProtocolMessagesFromSnapshot(
56
- previousMessages: Message[],
57
- visibleMessages: Message[],
58
- ): Message[] {
59
- if (previousMessages.length === 0) return [...visibleMessages];
60
-
61
- const visibleById = new Map(visibleMessages.map((message) => [message.id, message]));
62
- const previousVisibleIds = new Set(
63
- previousMessages
64
- .filter((message) => !shouldPreserveAcrossVisibleHistory(message))
65
- .map((message) => message.id),
66
- );
67
- const isSameVisibleThread = visibleMessages.some((message) => previousVisibleIds.has(message.id));
68
- if (!isSameVisibleThread) return [...visibleMessages];
69
-
70
- const merged: Message[] = [];
71
- const emitted = new Set<string>();
72
-
73
- for (const previous of previousMessages) {
74
- const currentVisible = visibleById.get(previous.id);
75
- if (currentVisible) {
76
- merged.push(currentVisible);
77
- emitted.add(currentVisible.id);
78
- continue;
79
- }
80
-
81
- if (shouldPreserveAcrossVisibleHistory(previous)) {
82
- merged.push(previous);
83
- emitted.add(previous.id);
84
- }
85
- }
86
-
87
- for (const message of visibleMessages) {
88
- if (!emitted.has(message.id)) {
89
- merged.push(message);
90
- }
91
- }
92
-
93
- return merged;
94
- }
95
-
96
- /**
97
- * Bridges callback-driven `AgentSubscriber` events into an async-iterable
98
- * queue the runner's generator drains. Single-producer / single-consumer.
99
- */
100
- class EventQueue<T> {
101
- private buffer: T[] = [];
102
- private resolve: (() => void) | null = null;
103
- private ended = false;
104
- private error: unknown = null;
105
-
106
- push(value: T): void {
107
- this.buffer.push(value);
108
- this.flush();
109
- }
110
-
111
- end(error?: unknown): void {
112
- this.ended = true;
113
- if (error !== undefined) this.error = error;
114
- this.flush();
115
- }
116
-
117
- private flush(): void {
118
- if (this.resolve) {
119
- const r = this.resolve;
120
- this.resolve = null;
121
- r();
122
- }
123
- }
124
-
125
- async *drain(): AsyncGenerator<T> {
126
- for (;;) {
127
- if (this.buffer.length > 0) {
128
- yield this.buffer.shift()!;
129
- continue;
130
- }
131
- if (this.ended) {
132
- if (this.error) throw this.error;
133
- return;
134
- }
135
- await new Promise<void>((r) => {
136
- this.resolve = r;
137
- });
138
- }
139
- }
140
- }
141
-
142
- /**
143
- * AG-UI Runner.
144
- *
145
- * Reusable across runs — instantiate once, call `run()` per turn batch.
146
- * Maintains `threadId` and the `HttpAgent` state snapshot across calls.
147
- */
148
- export class AGUIRunner {
149
- private readonly httpAgent: HttpAgent;
150
-
151
- constructor(options: AGUIRunnerOptions = {}) {
152
- this.httpAgent = new HttpAgent({
153
- url: options.url ?? "/api/agent",
154
- threadId: options.threadId ?? crypto.randomUUID(),
155
- ...(options.fetchFn ? { fetch: options.fetchFn } : {}),
156
- });
157
- }
158
-
159
- get threadId(): string {
160
- return this.httpAgent.threadId;
161
- }
162
-
163
- get state(): unknown {
164
- return this.httpAgent.state;
165
- }
166
-
167
- /**
168
- * Run a single AG-UI exchange (with multi-turn re-issue when client-side
169
- * tool results are produced) and yield normalized events.
170
- */
171
- async *run(input: AGUIRunInput): AsyncGenerator<RunnerEvent> {
172
- const { messages, state, registry, abortSignal, context } = input;
173
- const toolCalls = new Map<string, ToolCallState>();
174
- let currentMessages: Message[] = mergeProtocolMessagesFromSnapshot(
175
- this.httpAgent.messages,
176
- messages,
177
- );
178
-
179
- // Replace once at the top with the reconciled AG-UI history. assistant-ui
180
- // stores only visible chat turns, while AG-UI snapshots may contain
181
- // model-visible but UI-hidden protocol messages such as system/developer
182
- // guidance. Preserve those messages across turns when the visible history
183
- // belongs to the same thread so the next request remains a complete AG-UI
184
- // conversation without leaking system messages into the rendered chat.
185
- this.httpAgent.setMessages(currentMessages);
186
- if (state) this.httpAgent.setState(state);
187
-
188
- try {
189
- for (;;) {
190
- if (abortSignal?.aborted) return;
191
-
192
- const tools: Tool[] = registry.getActiveSchemas();
193
- const queue = new EventQueue<RunnerEvent>();
194
- const runId = crypto.randomUUID();
195
- // Per-turn text accumulator. The model can emit text BEFORE a
196
- // tool call in the same turn ("Looking up X..." then ui_navigate);
197
- // dropping it from the follow-up replay changes the next-turn
198
- // context (no server-side thread state — full history rides each
199
- // request) and causes inconsistent continuations.
200
- let turnAssistantText = "";
201
-
202
- // Build the request input now, before runAgent fires — dev tooling
203
- // consumes this snapshot via the request-sent event. `runId` is
204
- // pre-generated so the snapshot matches what runAgent emits.
205
- const runInputSnapshot: Record<string, unknown> = {
206
- threadId: this.httpAgent.threadId,
207
- runId,
208
- state: { ...((this.httpAgent.state as Record<string, unknown>) || {}), ...(state ?? {}) },
209
- messages: currentMessages,
210
- tools,
211
- context: context ?? [],
212
- };
213
-
214
- queue.push({ type: "turn-started" });
215
- queue.push({ type: "streaming-status", status: { status: "thinking" } });
216
- queue.push({ type: "request-sent", input: runInputSnapshot });
217
-
218
- const subscriber: AgentSubscriber = {
219
- onRunStartedEvent: () => {
220
- queue.push({ type: "run-started" });
221
- queue.push({ type: "streaming-status", status: { status: "thinking" } });
222
- },
223
- onTextMessageStartEvent: () => {
224
- queue.push({ type: "streaming-status", status: { status: "streaming" } });
225
- },
226
- onTextMessageContentEvent: ({ event }) => {
227
- turnAssistantText += event.delta;
228
- queue.push({ type: "text-delta", delta: event.delta });
229
- },
230
- onTextMessageEndEvent: () => {
231
- queue.push({ type: "text-message-end" });
232
- },
233
- onToolCallStartEvent: ({ event }) => {
234
- const id = event.toolCallId;
235
- const name = event.toolCallName;
236
- toolCalls.set(id, { id, name, args: "" });
237
- queue.push({
238
- type: "streaming-status",
239
- status: { status: "calling", toolName: name },
240
- });
241
- queue.push({
242
- type: "tool-call-start",
243
- id,
244
- name,
245
- isClientSide: registry.isRegistered(name),
246
- });
247
- },
248
- onToolCallArgsEvent: ({ event }) => {
249
- const tc = toolCalls.get(event.toolCallId);
250
- if (tc) tc.args += event.delta;
251
- queue.push({ type: "tool-call-args", id: event.toolCallId, delta: event.delta });
252
- },
253
- onToolCallEndEvent: async ({ event }) => {
254
- const tc = toolCalls.get(event.toolCallId);
255
- if (!tc) return;
256
-
257
- // Intercept client-side tools — execute locally and stash the
258
- // result so the multi-turn loop can replay it on the next turn.
259
- if (registry.isRegistered(tc.name)) {
260
- try {
261
- const resultJson = await registry.executeTool(tc.name, tc.args);
262
- tc.result = JSON.parse(resultJson);
263
- } catch (err) {
264
- tc.result = {
265
- error: err instanceof Error ? err.message : "Client-side tool failed",
266
- };
267
- tc.isError = true;
268
- }
269
- }
270
-
271
- queue.push({
272
- type: "tool-call-end",
273
- id: event.toolCallId,
274
- args: tc.args,
275
- result: tc.result,
276
- isError: tc.isError,
277
- });
278
- },
279
- onToolCallResultEvent: ({ event }) => {
280
- const tc = toolCalls.get(event.toolCallId);
281
- if (!tc) return;
282
-
283
- const content = event.content;
284
- let parsedResult: unknown = content;
285
- let isError = false;
286
- try {
287
- const parsed = JSON.parse(content);
288
- parsedResult = parsed;
289
- if (parsed && typeof parsed === "object" && "error" in parsed) {
290
- isError = true;
291
- }
292
- } catch {
293
- parsedResult = content;
294
- }
295
- tc.result = parsedResult;
296
- tc.isError = isError;
297
- queue.push({
298
- type: "tool-call-result",
299
- id: event.toolCallId,
300
- result: parsedResult,
301
- isError,
302
- });
303
- },
304
- onRunFinishedEvent: () => {
305
- queue.push({ type: "streaming-status", status: { status: "idle" } });
306
- queue.push({ type: "run-finished" });
307
- },
308
- onRunErrorEvent: ({ event }) => {
309
- queue.push({ type: "streaming-status", status: { status: "idle" } });
310
- queue.push({ type: "run-error", message: event.message ?? "Run error" });
311
- },
312
- };
313
-
314
- // Wire abort: AG-UI exposes abortRun(); call it when the caller's signal fires.
315
- const onAbort = () => this.httpAgent.abortRun();
316
- abortSignal?.addEventListener("abort", onAbort, { once: true });
317
-
318
- // Kick off the run — completion ends the queue. Errors during the
319
- // run surface via onRunErrorEvent; transport-level rejections
320
- // (e.g. fetch failure) are propagated through queue.end(err).
321
- const runPromise = this.httpAgent
322
- .runAgent({ runId, tools, context: context ?? [] }, subscriber)
323
- .then(() => queue.end())
324
- .catch((err: unknown) => queue.end(err));
325
-
326
- try {
327
- for await (const evt of queue.drain()) {
328
- yield evt;
329
- if (abortSignal?.aborted) {
330
- this.httpAgent.abortRun();
331
- return;
332
- }
333
- }
334
- } finally {
335
- abortSignal?.removeEventListener("abort", onAbort);
336
- await runPromise; // ensure the run task is settled
337
- }
338
-
339
- // Decide whether to re-issue: any client-side tool that resolved
340
- // during this turn and hasn't been replayed yet.
341
- const pendingClientTools = Array.from(toolCalls.values()).filter(
342
- (tc) => registry.isRegistered(tc.name) && tc.result !== undefined && !tc.followedUp,
343
- );
344
- if (pendingClientTools.length === 0) break;
345
-
346
- for (const tc of pendingClientTools) tc.followedUp = true;
347
-
348
- // Build follow-up messages with the SAME toolCallId the agent emitted.
349
- // Tool results are appended to the agent's message history (AG-UI
350
- // convention — append, never replace). Preserve any pre-tool text
351
- // the model emitted in this turn so the next-turn context matches
352
- // what the user actually saw.
353
- const assistantMsg: Message = {
354
- id: crypto.randomUUID(),
355
- role: "assistant",
356
- content: turnAssistantText,
357
- toolCalls: pendingClientTools.map((tc) => ({
358
- id: tc.id,
359
- type: "function" as const,
360
- function: { name: tc.name, arguments: tc.args },
361
- })),
362
- };
363
-
364
- const toolResultMsgs: Message[] = pendingClientTools.map((tc) => ({
365
- id: crypto.randomUUID(),
366
- role: "tool",
367
- toolCallId: tc.id,
368
- content: typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result),
369
- }));
370
-
371
- currentMessages = [...currentMessages, assistantMsg, ...toolResultMsgs];
372
- for (const m of [assistantMsg, ...toolResultMsgs]) {
373
- this.httpAgent.addMessage(m);
374
- }
375
- }
376
- } finally {
377
- // Normalized terminal status — idempotent if onRunFinishedEvent already
378
- // fired one. Consumers ignore duplicate idle transitions.
379
- yield { type: "streaming-status", status: { status: "idle" } };
380
- }
381
- }
382
- }
@@ -1,27 +0,0 @@
1
- import type { StreamingStatus } from "../store/streaming-status-store.ts";
2
-
3
- /**
4
- * Normalized event stream emitted by `AGUIRunner.run()`. The runner is
5
- * framework-agnostic; consumers (e.g. the assistant-ui shim in
6
- * `@iloveagents/foundry-web-ui`) translate these events into their own runtime model.
7
- *
8
- * `request-sent` carries the exact AG-UI request payload — used by host
9
- * dev-tooling to capture each turn.
10
- *
11
- * `turn-started` fires at the top of every iteration of the multi-turn loop
12
- * (initial request + each client-tool follow-up). Consumers reset their
13
- * per-turn buffers (e.g. `currentText`) here.
14
- */
15
- export type RunnerEvent =
16
- | { type: "request-sent"; input: Record<string, unknown> }
17
- | { type: "turn-started" }
18
- | { type: "run-started" }
19
- | { type: "text-delta"; delta: string }
20
- | { type: "text-message-end" }
21
- | { type: "tool-call-start"; id: string; name: string; isClientSide: boolean }
22
- | { type: "tool-call-args"; id: string; delta: string }
23
- | { type: "tool-call-end"; id: string; args: string; result?: unknown; isError?: boolean }
24
- | { type: "tool-call-result"; id: string; result: unknown; isError: boolean }
25
- | { type: "streaming-status"; status: StreamingStatus }
26
- | { type: "run-finished" }
27
- | { type: "run-error"; message: string };