@guuey/agent-client 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.
Files changed (47) hide show
  1. package/README.md +54 -7
  2. package/dist/error-codes.d.ts +87 -0
  3. package/dist/error-codes.d.ts.map +1 -0
  4. package/dist/error-codes.js +82 -0
  5. package/dist/errors.d.ts +39 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +36 -0
  8. package/dist/history.d.ts +1 -1
  9. package/dist/history.d.ts.map +1 -1
  10. package/dist/index.d.ts +13 -7
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +31 -6
  13. package/dist/invoke-turn.d.ts +101 -0
  14. package/dist/invoke-turn.d.ts.map +1 -0
  15. package/dist/invoke-turn.js +124 -0
  16. package/dist/react.d.ts +1 -1
  17. package/dist/react.d.ts.map +1 -1
  18. package/dist/react.js +1 -1
  19. package/dist/saturation-retry.d.ts +101 -0
  20. package/dist/saturation-retry.d.ts.map +1 -0
  21. package/dist/saturation-retry.js +207 -0
  22. package/dist/sse.d.ts +1 -1
  23. package/dist/sse.d.ts.map +1 -1
  24. package/dist/transport.d.ts +90 -0
  25. package/dist/transport.d.ts.map +1 -0
  26. package/dist/transport.js +189 -0
  27. package/dist/types.d.ts +101 -3
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/useAgentInvoke.d.ts +29 -1
  30. package/dist/useAgentInvoke.d.ts.map +1 -1
  31. package/dist/useAgentInvoke.js +297 -86
  32. package/dist/web-adapters.d.ts +30 -34
  33. package/dist/web-adapters.d.ts.map +1 -1
  34. package/dist/web-adapters.js +72 -123
  35. package/package.json +11 -5
  36. package/src/error-codes.ts +89 -0
  37. package/src/errors.ts +35 -0
  38. package/src/history.ts +1 -1
  39. package/src/index.ts +50 -10
  40. package/src/invoke-turn.ts +187 -0
  41. package/src/react.ts +7 -1
  42. package/src/saturation-retry.ts +247 -0
  43. package/src/sse.ts +1 -1
  44. package/src/transport.ts +260 -0
  45. package/src/types.ts +102 -3
  46. package/src/useAgentInvoke.ts +288 -86
  47. package/src/web-adapters.ts +92 -134
@@ -7,35 +7,17 @@
7
7
  * (the functions guard on `typeof window`).
8
8
  */
9
9
  import {
10
+ createMcpUiActionRelay,
10
11
  createMcpUiResourceReader,
11
12
  type McpResourceReadResult,
13
+ type McpToolCallResult,
14
+ type McpToolStructuredContent,
12
15
  type ResolvedViewMount,
16
+ type UiActionRequest,
13
17
  } from "@guuey/mcp-apps-host";
14
- import type {
15
- AgentInvokeAdapters,
16
- InvokeRequest,
17
- InvokeTransport,
18
- ThreadIdStore,
19
- } from "./types";
20
- import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
21
-
22
- /**
23
- * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
24
- * SSE stream opens). Carries the pod's structured `{ code, message }` when
25
- * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
26
- * generation limit…") the chat UI should surface — falling back to the bare
27
- * status for non-JSON failures.
28
- */
29
- export class AgentResponseError extends Error {
30
- constructor(
31
- message: string,
32
- readonly status: number,
33
- readonly code?: string,
34
- ) {
35
- super(message);
36
- this.name = "AgentResponseError";
37
- }
38
- }
18
+ import type { AgentInvokeAdapters, InvokeTransport, ThreadIdStore } from "./types.js";
19
+ import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
20
+ import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
39
21
 
40
22
  /** Persists the threadId in `window.localStorage` (synchronously). */
41
23
  export const localStorageThreadStore: ThreadIdStore = {
@@ -65,113 +47,6 @@ export function webGenerateId(): string {
65
47
  return `cmid-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
66
48
  }
67
49
 
68
- /**
69
- * Header carrying a caller-owned anonymous guest secret. A LOCAL MIRROR of the
70
- * two server-side constants — the pod's `GUEST_HEADER_NAME`
71
- * (`backend/services/nocode-runtime/src/identity.ts`) and the read plane's
72
- * `GUEST_HEADER` (`backend/amplify/functions/publicApi/identity.ts`) — because
73
- * this is a published npm package and cannot take a `@guuey-private` dep (same
74
- * arrangement as `@guuey/host`'s mirrored fs-contract constants). The string is
75
- * a wire contract: both planes already advertise it in
76
- * `Access-Control-Allow-Headers`, so changing it is a breaking protocol change,
77
- * not a rename.
78
- */
79
- const GUEST_HEADER = "x-guuey-guest";
80
-
81
- /**
82
- * A well-formed guest secret: exactly 32 bytes as 64 LOWERCASE hex chars —
83
- * the shape `crypto.getRandomValues` + hex-encoding mints.
84
- *
85
- * Deliberately stricter than the server's `/^[a-f0-9]{64}$/i` (pod
86
- * `identity.ts`, publicApi `identity.ts`): both sides lowercase before
87
- * hashing, so an uppercase secret would in fact be accepted, but the only
88
- * supported mint path emits lowercase and a non-canonical value means the
89
- * caller's storage is not what this adapter expects. Anything that fails is
90
- * IGNORED — the request falls through to cookie mode rather than sending a
91
- * secret the two identity planes might key differently.
92
- */
93
- const GUEST_SECRET_RE = /^[0-9a-f]{64}$/;
94
-
95
- /**
96
- * Narrow a caller-supplied guest secret to a value that is safe to put on the
97
- * wire, or `null`. The single gate for the header: every write of
98
- * {@link GUEST_HEADER} in this module goes through it, so a malformed secret
99
- * can never reach a request. The value is never logged (here or anywhere on
100
- * this path) — it IS the anonymous identity, so a leak is an impersonation.
101
- */
102
- function sendableGuestSecret(secret: string | null | undefined): string | null {
103
- return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
104
- }
105
-
106
- /**
107
- * Web SSE transport. Exactly ONE identity carrier per request, in order:
108
- *
109
- * 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
110
- * by their verified access token (the same identity the history read
111
- * plane uses, so persisted threads round-trip on reload).
112
- * 2. a well-formed `guestSecret` → `x-guuey-guest` — the caller owns and
113
- * persists its own anonymous secret. The path for hosts with no usable
114
- * cookie jar: React-Native, and the embedded widget, whose third-party
115
- * iframe cannot rely on the pod's cookie surviving browser partitioning.
116
- * The pod never mints a cookie for a header client.
117
- * 3. neither → `credentials: "include"`, which round-trips the HttpOnly
118
- * `guuey_guest` cookie the pod mints for anonymous browser callers.
119
- *
120
- * Never two at once: a bearer wins over a guest secret, and a request that
121
- * carries either header does NOT also send cookie credentials.
122
- *
123
- * Reads the body via `ReadableStream.getReader()` (browser).
124
- */
125
- export async function* fetchStreamTransport(
126
- req: InvokeRequest,
127
- accessToken?: string | null,
128
- guestSecret?: string | null,
129
- ): AsyncGenerator<string> {
130
- const headers: Record<string, string> = {
131
- "Content-Type": "application/json",
132
- Accept: "text/event-stream",
133
- };
134
- const init: RequestInit = {
135
- method: "POST",
136
- signal: req.signal,
137
- headers,
138
- body: JSON.stringify(req.body),
139
- };
140
- const guest = sendableGuestSecret(guestSecret);
141
- if (accessToken) {
142
- headers.Authorization = `Bearer ${accessToken}`;
143
- } else if (guest) {
144
- headers[GUEST_HEADER] = guest;
145
- } else {
146
- init.credentials = "include";
147
- }
148
- const resp = await fetch(req.url, init);
149
- if (!resp.ok || !resp.body) {
150
- // Surface a structured pod error ({ code, message }) when present — e.g. a
151
- // QUOTA_EXCEEDED 429 carries an upgrade message the UI should show. Fall
152
- // back to the bare status for non-JSON failures.
153
- const body: unknown = await resp.json().catch(() => null);
154
- let message = `agent responded ${resp.status}`;
155
- let code: string | undefined;
156
- if (body !== null && typeof body === "object") {
157
- if ("message" in body && typeof body.message === "string" && body.message) {
158
- message = body.message;
159
- }
160
- if ("code" in body && typeof body.code === "string") {
161
- code = body.code;
162
- }
163
- }
164
- throw new AgentResponseError(message, resp.status, code);
165
- }
166
- const reader = resp.body.getReader();
167
- const decoder = new TextDecoder();
168
- for (;;) {
169
- const { value, done } = await reader.read();
170
- if (done) break;
171
- yield decoder.decode(value, { stream: true });
172
- }
173
- }
174
-
175
50
  export interface CreateWebAdaptersOptions {
176
51
  /**
177
52
  * Public read-plane base (ending in `/v1`) for transcript history. When
@@ -253,10 +128,16 @@ export function createWebAdapters(
253
128
  const { apiBaseUrl, getAccessToken, getGuestSecret } = opts;
254
129
 
255
130
  const transport: InvokeTransport = async function* (req) {
256
- const token = getAccessToken ? await getAccessToken() : null;
257
131
  // Both candidates go to the transport; it owns the precedence (and the
258
132
  // never-two-carriers rule) so there is exactly one place that decides.
259
- yield* fetchStreamTransport(req, token, getGuestSecret ? getGuestSecret() : null);
133
+ // The bearer goes through as the PROVIDER, not a pre-resolved value:
134
+ // the transport re-asks it per attempt, so a cold-start retry after a
135
+ // backoff wait re-reads a fresh token instead of replaying one that may
136
+ // have expired during the wait (the same reason Portal's RN transport
137
+ // resolves inside its generator).
138
+ yield* fetchStreamTransport(req, null, getGuestSecret ? getGuestSecret() : null, {
139
+ getBearer: getAccessToken,
140
+ });
260
141
  };
261
142
 
262
143
  const adapters: AgentInvokeAdapters = {
@@ -406,3 +287,80 @@ export function createUiResourceReader(
406
287
  };
407
288
  return createMcpUiResourceReader({ readResource });
408
289
  }
290
+
291
+ /** Options for {@link createUiActionRelay} — same credential surface as the reader. */
292
+ export interface CreateUiActionRelayOptions {
293
+ /** The guuey public API base (`…/v1`). */
294
+ apiBaseUrl: string;
295
+ /** The thread whose persisted cards this relay may act for. */
296
+ threadId: string;
297
+ /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
298
+ getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
299
+ /** Caller-owned anonymous guest secret (widget / guest chat). */
300
+ guestSecret?: string | null;
301
+ /** Injectable for tests. */
302
+ fetchImpl?: typeof fetch;
303
+ }
304
+
305
+ /**
306
+ * Build the card action relay over guuey's authenticated `tools/call` proxy
307
+ * (guuey#158: `POST /v1/threads/:threadId/ui-action`) — the mirror of
308
+ * {@link createUiResourceReader}. Allowlisting, arm narrowing, and the
309
+ * never-reject contract live in `@guuey/mcp-apps-host`'s
310
+ * `createMcpUiActionRelay`; only the transport is guuey-shaped. The proxy
311
+ * owns EVERYTHING trust-shaped (identity, thread ownership, the
312
+ * locator-to-thread guard, its own server-side allowlist, the per-user
313
+ * federation mint) — and every non-OK here collapses to `undefined`, which
314
+ * the host relay answers in-band as an `isError` result, never a thrown
315
+ * error into the sandbox bridge.
316
+ */
317
+ export function createUiActionRelay(
318
+ options: CreateUiActionRelayOptions,
319
+ ): (request: UiActionRequest) => Promise<McpToolCallResult> {
320
+ const fetchImpl = options.fetchImpl ?? fetch;
321
+ const callTool = async (
322
+ uri: string,
323
+ name: string,
324
+ args: McpToolStructuredContent | undefined,
325
+ ): Promise<unknown> => {
326
+ const headers: Record<string, string> = { "content-type": "application/json" };
327
+ const token = options.getAccessToken ? await options.getAccessToken() : null;
328
+ const guest = sendableGuestSecret(options.guestSecret);
329
+ if (token) {
330
+ headers["authorization"] = `Bearer ${token}`;
331
+ } else if (guest) {
332
+ headers[GUEST_HEADER] = guest;
333
+ }
334
+ const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
335
+ const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
336
+ let res: Response;
337
+ try {
338
+ res = await fetchImpl(requestUrl, { method: "POST", headers, body });
339
+ } catch {
340
+ return undefined; // transport failure — the host relay answers in-band
341
+ }
342
+ // One forceRefresh retry on 401 with a bearer in play — the same
343
+ // expired-but-refreshable recovery the reader performs.
344
+ if (res.status === 401 && options.getAccessToken) {
345
+ const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
346
+ if (fresh) {
347
+ try {
348
+ res = await fetchImpl(requestUrl, {
349
+ method: "POST",
350
+ headers: { ...headers, authorization: `Bearer ${fresh}` },
351
+ body,
352
+ });
353
+ } catch {
354
+ return undefined;
355
+ }
356
+ }
357
+ }
358
+ if (!res.ok) return undefined;
359
+ try {
360
+ return (await res.json()) as unknown;
361
+ } catch {
362
+ return undefined;
363
+ }
364
+ };
365
+ return createMcpUiActionRelay({ callTool });
366
+ }