@iloveagents/foundry-agent 0.1.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/AGENTS.md +91 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/package.json +37 -0
- package/src/__tests__/agui-runner.test.ts +329 -0
- package/src/__tests__/auth-store.test.ts +37 -0
- package/src/__tests__/citation-store.test.ts +46 -0
- package/src/__tests__/client-tool-registry.test.ts +84 -0
- package/src/__tests__/service-fetch.test.ts +186 -0
- package/src/__tests__/streaming-status-store.test.ts +22 -0
- package/src/__tests__/token-fetch.test.ts +65 -0
- package/src/client/agui-runner.ts +330 -0
- package/src/client/runner-events.ts +27 -0
- package/src/client/service-fetch.ts +112 -0
- package/src/index.ts +28 -0
- package/src/msal/auth-config.ts +90 -0
- package/src/msal/auth-store.ts +72 -0
- package/src/msal/index.ts +10 -0
- package/src/msal/token-fetch.ts +30 -0
- package/src/store/citation-store.ts +45 -0
- package/src/store/streaming-status-store.ts +21 -0
- package/src/tools/registry.ts +112 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +8 -0
|
@@ -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
|
+
|
|
15
|
+
export interface ServiceFetchOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Acquire an access token for outgoing requests. Return `null` to skip
|
|
18
|
+
* token attachment (callers without auth — e.g. local dev — pass through
|
|
19
|
+
* to native fetch).
|
|
20
|
+
*/
|
|
21
|
+
acquireToken: () => Promise<string | null>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Router FQDN for production (e.g. `https://lastspace-prod.eastus2.example.com`).
|
|
25
|
+
* Empty / undefined leaves the URL untouched (Vite proxy handles routing
|
|
26
|
+
* in dev).
|
|
27
|
+
*/
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve `window.location.origin` (or equivalent) for the current runtime.
|
|
32
|
+
* Defaults to a browser-aware lookup; non-browser callers can override.
|
|
33
|
+
*/
|
|
34
|
+
originResolver?: () => string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ServiceFetch = (
|
|
38
|
+
input: string | URL | Request,
|
|
39
|
+
init?: RequestInit,
|
|
40
|
+
) => Promise<Response>;
|
|
41
|
+
|
|
42
|
+
const defaultOriginResolver = (): string =>
|
|
43
|
+
typeof window !== "undefined" ? window.location.origin : "";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a service-fetch function. Returns a function with the same shape as
|
|
47
|
+
* `fetch` that rewrites URLs + attaches the Bearer token from `acquireToken`.
|
|
48
|
+
*/
|
|
49
|
+
export function createServiceFetch(options: ServiceFetchOptions): ServiceFetch {
|
|
50
|
+
const routerBase = (options.baseUrl ?? "").replace(/\/+$/, "");
|
|
51
|
+
const resolveOrigin = options.originResolver ?? defaultOriginResolver;
|
|
52
|
+
|
|
53
|
+
function resolveUrl(url: string): string {
|
|
54
|
+
if (!routerBase) return url; // dev: Vite proxy handles routing
|
|
55
|
+
const origin = resolveOrigin();
|
|
56
|
+
const rel = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
|
|
57
|
+
return `${routerBase}${rel}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return async function serviceFetch(input, init): Promise<Response> {
|
|
61
|
+
const url =
|
|
62
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
63
|
+
const resolvedUrl = resolveUrl(url);
|
|
64
|
+
|
|
65
|
+
// Merge headers from a `Request` input (if provided), then `init`. The
|
|
66
|
+
// Request-input branch must preserve method / body / credentials /
|
|
67
|
+
// signal / etc. — silently dropping them by reading only `.url` would
|
|
68
|
+
// turn POST/PUT into GET and drop required headers.
|
|
69
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
70
|
+
if (init?.headers) {
|
|
71
|
+
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
|
72
|
+
}
|
|
73
|
+
if (!headers.has("Authorization")) {
|
|
74
|
+
try {
|
|
75
|
+
const token = await options.acquireToken();
|
|
76
|
+
if (token) {
|
|
77
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Token acquisition failed — proceed without
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (input instanceof Request) {
|
|
85
|
+
// Build a fresh Request against the rewritten URL, copying every
|
|
86
|
+
// field the runtime exposes on the original — explicit field copy
|
|
87
|
+
// is more portable than `new Request(url, originalRequest)`, which
|
|
88
|
+
// some runtimes (older undici/JSDOM in particular) do not honor
|
|
89
|
+
// for method/body. `init` then overrides anything the caller passed.
|
|
90
|
+
const cloned = input.clone();
|
|
91
|
+
const requestInit: RequestInit = {
|
|
92
|
+
method: cloned.method,
|
|
93
|
+
body:
|
|
94
|
+
cloned.method === "GET" || cloned.method === "HEAD" ? undefined : cloned.body,
|
|
95
|
+
headers,
|
|
96
|
+
credentials: cloned.credentials,
|
|
97
|
+
mode: cloned.mode,
|
|
98
|
+
cache: cloned.cache,
|
|
99
|
+
redirect: cloned.redirect,
|
|
100
|
+
referrer: cloned.referrer,
|
|
101
|
+
integrity: cloned.integrity,
|
|
102
|
+
signal: cloned.signal,
|
|
103
|
+
};
|
|
104
|
+
// Streaming bodies need duplex: "half"; harmless when there's no body.
|
|
105
|
+
if (cloned.body !== null) {
|
|
106
|
+
(requestInit as RequestInit & { duplex?: string }).duplex = "half";
|
|
107
|
+
}
|
|
108
|
+
return fetch(resolvedUrl, { ...requestInit, ...init, headers });
|
|
109
|
+
}
|
|
110
|
+
return fetch(resolvedUrl, { ...init, headers });
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// --- AG-UI runner ---
|
|
2
|
+
export { AGUIRunner, type AGUIRunnerOptions, type AGUIRunInput } from "./client/agui-runner.ts";
|
|
3
|
+
export type { RunnerEvent } from "./client/runner-events.ts";
|
|
4
|
+
|
|
5
|
+
// --- Service fetch factory ---
|
|
6
|
+
export {
|
|
7
|
+
createServiceFetch,
|
|
8
|
+
type ServiceFetch,
|
|
9
|
+
type ServiceFetchOptions,
|
|
10
|
+
} from "./client/service-fetch.ts";
|
|
11
|
+
|
|
12
|
+
// --- Tool registry ---
|
|
13
|
+
export {
|
|
14
|
+
clientToolRegistry,
|
|
15
|
+
type ClientToolEntry,
|
|
16
|
+
type ToolRegistry,
|
|
17
|
+
} from "./tools/registry.ts";
|
|
18
|
+
|
|
19
|
+
// --- Stores (vanilla) ---
|
|
20
|
+
export {
|
|
21
|
+
streamingStatusStore,
|
|
22
|
+
type StreamingStatus,
|
|
23
|
+
} from "./store/streaming-status-store.ts";
|
|
24
|
+
export {
|
|
25
|
+
citationStore,
|
|
26
|
+
type CitationResult,
|
|
27
|
+
type CitationHandler,
|
|
28
|
+
} from "./store/citation-store.ts";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSAL configuration types and PublicClientApplication singleton.
|
|
3
|
+
*
|
|
4
|
+
* `initializeMsal()` creates and caches the singleton PublicClientApplication.
|
|
5
|
+
* `getMsalInstance()` returns null until initialization has completed.
|
|
6
|
+
*
|
|
7
|
+
* `@azure/msal-browser` is loaded via dynamic import so `@iloveagents/foundry-agent`
|
|
8
|
+
* does not pull MSAL into bundles that don't need it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface MsalAuthConfig {
|
|
12
|
+
clientId: string;
|
|
13
|
+
authority: string; // https://login.microsoftonline.com/{tenantId}
|
|
14
|
+
redirectUri: string; // window.location.origin
|
|
15
|
+
apiScope: string; // api://{apiClientId}/access_as_user
|
|
16
|
+
postLogoutRedirectUri?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MsalAccountInfo {
|
|
20
|
+
username: string;
|
|
21
|
+
localAccountId: string;
|
|
22
|
+
name?: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MsalClientApplication {
|
|
26
|
+
initialize(): Promise<void>;
|
|
27
|
+
handleRedirectPromise(): Promise<unknown>;
|
|
28
|
+
getAllAccounts(): MsalAccountInfo[];
|
|
29
|
+
loginRedirect(request: { scopes: string[]; prompt?: string }): Promise<void>;
|
|
30
|
+
logoutRedirect(): Promise<void>;
|
|
31
|
+
setActiveAccount(account: MsalAccountInfo | null): void;
|
|
32
|
+
acquireTokenSilent(request: {
|
|
33
|
+
scopes: string[];
|
|
34
|
+
account: MsalAccountInfo;
|
|
35
|
+
}): Promise<{ accessToken: string }>;
|
|
36
|
+
acquireTokenRedirect(request: {
|
|
37
|
+
scopes: string[];
|
|
38
|
+
account: MsalAccountInfo;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
acquireTokenPopup(request: {
|
|
41
|
+
scopes: string[];
|
|
42
|
+
account: MsalAccountInfo;
|
|
43
|
+
}): Promise<{ accessToken: string }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let msalInstance: MsalClientApplication | null = null;
|
|
47
|
+
let msalConfig: MsalAuthConfig | null = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Initialize the MSAL singleton. Must be called once before rendering.
|
|
51
|
+
* Dynamically imports `@azure/msal-browser` so the dependency stays optional.
|
|
52
|
+
*/
|
|
53
|
+
export async function initializeMsal(
|
|
54
|
+
config: MsalAuthConfig,
|
|
55
|
+
): Promise<MsalClientApplication> {
|
|
56
|
+
if (msalInstance) return msalInstance;
|
|
57
|
+
|
|
58
|
+
const { PublicClientApplication: PCA } = await import("@azure/msal-browser");
|
|
59
|
+
|
|
60
|
+
const msalConfiguration = {
|
|
61
|
+
auth: {
|
|
62
|
+
clientId: config.clientId,
|
|
63
|
+
authority: config.authority,
|
|
64
|
+
redirectUri: config.redirectUri,
|
|
65
|
+
postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,
|
|
66
|
+
},
|
|
67
|
+
cache: {
|
|
68
|
+
// localStorage is required for Playwright E2E tests — sessionStorage
|
|
69
|
+
// is not preserved across page navigations in the Playwright context.
|
|
70
|
+
cacheLocation: "localStorage",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
msalInstance = new (PCA as new (config: typeof msalConfiguration) => MsalClientApplication)(
|
|
75
|
+
msalConfiguration,
|
|
76
|
+
);
|
|
77
|
+
await msalInstance.initialize();
|
|
78
|
+
msalConfig = config;
|
|
79
|
+
return msalInstance;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Get the MSAL instance (null when not configured). */
|
|
83
|
+
export function getMsalInstance(): MsalClientApplication | null {
|
|
84
|
+
return msalInstance;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Get the MSAL auth config (null when not configured). */
|
|
88
|
+
export function getMsalConfig(): MsalAuthConfig | null {
|
|
89
|
+
return msalConfig;
|
|
90
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
import { getMsalInstance, getMsalConfig } from "./auth-config.ts";
|
|
3
|
+
|
|
4
|
+
export interface AuthUser {
|
|
5
|
+
name: string;
|
|
6
|
+
email: string;
|
|
7
|
+
avatar?: string;
|
|
8
|
+
oid?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AuthState {
|
|
12
|
+
user: AuthUser | null;
|
|
13
|
+
isAuthenticated: boolean;
|
|
14
|
+
|
|
15
|
+
/** Sign in with the given user. Called by AuthProvider after MSAL login. */
|
|
16
|
+
signIn: (user: AuthUser) => void;
|
|
17
|
+
|
|
18
|
+
/** Sign out and clear user state. Triggers MSAL logout when configured. */
|
|
19
|
+
signOut: () => void;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Acquire an access token for the given audience.
|
|
23
|
+
* Returns null when MSAL is not configured (local dev).
|
|
24
|
+
*/
|
|
25
|
+
getAccessToken: (audience?: "api" | "spaces") => Promise<string | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const authStore = createStore<AuthState>((set) => ({
|
|
29
|
+
user: null,
|
|
30
|
+
isAuthenticated: false,
|
|
31
|
+
|
|
32
|
+
signIn: (user) => set({ user, isAuthenticated: true }),
|
|
33
|
+
|
|
34
|
+
signOut: () => {
|
|
35
|
+
set({ user: null, isAuthenticated: false });
|
|
36
|
+
const msal = getMsalInstance();
|
|
37
|
+
if (msal) {
|
|
38
|
+
msal.logoutRedirect();
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
getAccessToken: async (_audience = "api") => {
|
|
43
|
+
const msal = getMsalInstance();
|
|
44
|
+
const config = getMsalConfig();
|
|
45
|
+
if (!msal || !config) return null;
|
|
46
|
+
|
|
47
|
+
const accounts = msal.getAllAccounts();
|
|
48
|
+
if (accounts.length === 0) return null;
|
|
49
|
+
|
|
50
|
+
// Single API-scoped token — Spaces accepts both audiences (multi-audience JWT)
|
|
51
|
+
const scope = config.apiScope;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await msal.acquireTokenSilent({
|
|
55
|
+
scopes: [scope],
|
|
56
|
+
account: accounts[0],
|
|
57
|
+
});
|
|
58
|
+
return result.accessToken;
|
|
59
|
+
} catch {
|
|
60
|
+
// Silent acquisition failed — trigger interactive redirect
|
|
61
|
+
try {
|
|
62
|
+
await msal.acquireTokenRedirect({
|
|
63
|
+
scopes: [scope],
|
|
64
|
+
account: accounts[0],
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// Redirect will navigate away; nothing to return
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { authStore, type AuthUser } from "./auth-store.ts";
|
|
2
|
+
export {
|
|
3
|
+
type MsalAuthConfig,
|
|
4
|
+
type MsalAccountInfo,
|
|
5
|
+
type MsalClientApplication,
|
|
6
|
+
initializeMsal,
|
|
7
|
+
getMsalInstance,
|
|
8
|
+
getMsalConfig,
|
|
9
|
+
} from "./auth-config.ts";
|
|
10
|
+
export { tokenFetch } from "./token-fetch.ts";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-aware fetch wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Acquires a Bearer token from MSAL (if configured) and attaches it to
|
|
5
|
+
* every outgoing request. All requests use the API scope — the API backend
|
|
6
|
+
* acts as a gateway and exchanges tokens server-side via OBO when calling
|
|
7
|
+
* downstream services (e.g., Spaces).
|
|
8
|
+
*
|
|
9
|
+
* When MSAL is not configured, behaves identically to native `fetch()`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { authStore } from "./auth-store.ts";
|
|
13
|
+
|
|
14
|
+
export async function tokenFetch(
|
|
15
|
+
input: string | URL | Request,
|
|
16
|
+
init?: RequestInit,
|
|
17
|
+
): Promise<Response> {
|
|
18
|
+
const token = await authStore.getState().getAccessToken("api");
|
|
19
|
+
|
|
20
|
+
if (!token) {
|
|
21
|
+
return fetch(input, init);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
25
|
+
if (init?.headers) {
|
|
26
|
+
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
|
27
|
+
}
|
|
28
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
29
|
+
return fetch(input, { ...init, headers });
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
|
|
3
|
+
/** Citation result shape — matches the search result format. */
|
|
4
|
+
export interface CitationResult {
|
|
5
|
+
chunk_id: string;
|
|
6
|
+
entity_id: string;
|
|
7
|
+
entity_name: string;
|
|
8
|
+
content: string;
|
|
9
|
+
page_number: number;
|
|
10
|
+
bounding_regions: string;
|
|
11
|
+
/** Semantic reranker score (0-4) or RRF score (~0.03) */
|
|
12
|
+
score: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Handler for citation clicks — registered by feature modules (e.g., SPACES). */
|
|
16
|
+
export interface CitationHandler {
|
|
17
|
+
openCitation: (result: CitationResult) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CitationState {
|
|
21
|
+
/** Current search results (from the most recent search tool call) */
|
|
22
|
+
results: CitationResult[];
|
|
23
|
+
/** Optional handler for opening citations (registered by feature modules) */
|
|
24
|
+
handler: CitationHandler | null;
|
|
25
|
+
/** Store search results for citation resolution */
|
|
26
|
+
setResults: (results: CitationResult[]) => void;
|
|
27
|
+
/** Register a handler for citation click actions */
|
|
28
|
+
setHandler: (handler: CitationHandler) => void;
|
|
29
|
+
/** Clear all citations (e.g. on new conversation) */
|
|
30
|
+
clear: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Vanilla store for the citation cache so `[n]` markers in chat messages
|
|
35
|
+
* resolve to clickable deep links. Search tool calls populate results; the
|
|
36
|
+
* markdown renderer looks them up by index. A `CitationHandler` (registered
|
|
37
|
+
* by feature modules) handles the click action.
|
|
38
|
+
*/
|
|
39
|
+
export const citationStore = createStore<CitationState>((set) => ({
|
|
40
|
+
results: [],
|
|
41
|
+
handler: null,
|
|
42
|
+
setResults: (results) => set({ results }),
|
|
43
|
+
setHandler: (handler) => set({ handler }),
|
|
44
|
+
clear: () => set({ results: [], handler: null }),
|
|
45
|
+
}));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
|
|
3
|
+
/** Streaming status emitted by the AG-UI runner. */
|
|
4
|
+
export interface StreamingStatus {
|
|
5
|
+
status: "thinking" | "calling" | "streaming" | "idle";
|
|
6
|
+
toolName?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface StreamingStatusState {
|
|
10
|
+
streamingStatus: StreamingStatus;
|
|
11
|
+
setStreamingStatus: (status: StreamingStatus) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Vanilla store for streaming status. Use `useStore(streamingStatusStore, ...)`
|
|
16
|
+
* from the React entry of zustand for hook-style subscriptions.
|
|
17
|
+
*/
|
|
18
|
+
export const streamingStatusStore = createStore<StreamingStatusState>((set) => ({
|
|
19
|
+
streamingStatus: { status: "idle" },
|
|
20
|
+
setStreamingStatus: (streamingStatus) => set({ streamingStatus }),
|
|
21
|
+
}));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
import type { Tool as AGUITool } from "@ag-ui/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dynamic Client Tool Registry — vanilla zustand store for client-side AG-UI
|
|
6
|
+
* tools.
|
|
7
|
+
*
|
|
8
|
+
* Tools are split into two categories:
|
|
9
|
+
* - **Global tools**: always available (navigate, set_theme, open_panel, ...)
|
|
10
|
+
* - **Page tools**: registered by the active page via the host shell, removed
|
|
11
|
+
* on unmount.
|
|
12
|
+
*
|
|
13
|
+
* The runner reads from this store on every request to get the current tool
|
|
14
|
+
* schemas and executors. Pages declare their capabilities by calling
|
|
15
|
+
* `registerPageTools()` — no need to edit core files.
|
|
16
|
+
*/
|
|
17
|
+
export interface ClientToolEntry {
|
|
18
|
+
/** Tool name (must be unique across global + page tools) */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Tool description for the LLM */
|
|
21
|
+
description: string;
|
|
22
|
+
/** JSON Schema for tool parameters */
|
|
23
|
+
parameters: Record<string, unknown>;
|
|
24
|
+
/** Execution function — called by the runner at TOOL_CALL_END */
|
|
25
|
+
execute: (argsJson: string) => Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ClientToolRegistryState {
|
|
29
|
+
globalTools: Map<string, ClientToolEntry>;
|
|
30
|
+
pageTools: Map<string, ClientToolEntry>;
|
|
31
|
+
|
|
32
|
+
/** Register a global tool (called once at app init) */
|
|
33
|
+
registerGlobal: (tool: ClientToolEntry) => void;
|
|
34
|
+
|
|
35
|
+
/** Register page-scoped tools (called by the host on mount) */
|
|
36
|
+
registerPageTools: (tools: ClientToolEntry[]) => void;
|
|
37
|
+
|
|
38
|
+
/** Remove page-scoped tools (called by the host on unmount) */
|
|
39
|
+
clearPageTools: () => void;
|
|
40
|
+
|
|
41
|
+
/** Get all active tool schemas for the runner */
|
|
42
|
+
getActiveSchemas: () => AGUITool[];
|
|
43
|
+
|
|
44
|
+
/** Check if a tool name is registered */
|
|
45
|
+
isRegistered: (name: string) => boolean;
|
|
46
|
+
|
|
47
|
+
/** Execute a registered tool by name */
|
|
48
|
+
executeTool: (name: string, argsJson: string) => Promise<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const clientToolRegistry = createStore<ClientToolRegistryState>((set, get) => ({
|
|
52
|
+
globalTools: new Map(),
|
|
53
|
+
pageTools: new Map(),
|
|
54
|
+
|
|
55
|
+
registerGlobal: (tool) => {
|
|
56
|
+
const { globalTools } = get();
|
|
57
|
+
const next = new Map(globalTools);
|
|
58
|
+
next.set(tool.name, tool);
|
|
59
|
+
set({ globalTools: next });
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
registerPageTools: (tools) => {
|
|
63
|
+
const next = new Map<string, ClientToolEntry>();
|
|
64
|
+
for (const tool of tools) {
|
|
65
|
+
next.set(tool.name, tool);
|
|
66
|
+
}
|
|
67
|
+
set({ pageTools: next });
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
clearPageTools: () => {
|
|
71
|
+
set({ pageTools: new Map() });
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
getActiveSchemas: () => {
|
|
75
|
+
const { globalTools, pageTools } = get();
|
|
76
|
+
// Merge: page tools override global tools with the same name
|
|
77
|
+
const merged = new Map<string, ClientToolEntry>();
|
|
78
|
+
for (const tool of globalTools.values()) {
|
|
79
|
+
merged.set(tool.name, tool);
|
|
80
|
+
}
|
|
81
|
+
for (const tool of pageTools.values()) {
|
|
82
|
+
merged.set(tool.name, tool); // page overrides global
|
|
83
|
+
}
|
|
84
|
+
return Array.from(merged.values()).map((tool) => ({
|
|
85
|
+
name: tool.name,
|
|
86
|
+
description: tool.description,
|
|
87
|
+
parameters: tool.parameters,
|
|
88
|
+
}));
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
isRegistered: (name) => {
|
|
92
|
+
const { globalTools, pageTools } = get();
|
|
93
|
+
return pageTools.has(name) || globalTools.has(name);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
executeTool: async (name, argsJson) => {
|
|
97
|
+
const { globalTools, pageTools } = get();
|
|
98
|
+
// Page tools take precedence
|
|
99
|
+
const tool = pageTools.get(name) ?? globalTools.get(name);
|
|
100
|
+
if (!tool) {
|
|
101
|
+
return JSON.stringify({ error: `Unknown client tool: ${name}` });
|
|
102
|
+
}
|
|
103
|
+
return tool.execute(argsJson);
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
/** Read-only registry view — what the runner consumes. */
|
|
108
|
+
export interface ToolRegistry {
|
|
109
|
+
isRegistered: (name: string) => boolean;
|
|
110
|
+
executeTool: (name: string, argsJson: string) => Promise<string>;
|
|
111
|
+
getActiveSchemas: () => AGUITool[];
|
|
112
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"noEmit": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules"]
|
|
15
|
+
}
|