@guuey/agent-client 0.4.0 → 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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The web SSE invoke transport, and the guest-identity wire pieces it shares
3
+ * with the read-plane adapters.
4
+ *
5
+ * Its own module (rather than living in `./web-adapters.ts`) for the same
6
+ * reason `AgentResponseError` lives in `./errors.ts`, one level down: the
7
+ * web-adapter bundle takes VALUE imports of `@guuey/mcp-apps-host` (the
8
+ * ui-resource reader / action-relay assemblies), so a consumer that only
9
+ * wants the transport — a custom chat surface with its own card layer, or
10
+ * none — would drag the whole host-role graph into its build, and any
11
+ * version skew between the two packages becomes that consumer's build
12
+ * failure (guuey#186 G2). This module's import closure is
13
+ * `types`/`errors`/`saturation-retry` only; `./web-adapters.ts` imports
14
+ * from HERE, never the reverse, and `@guuey/agent-client/transport`
15
+ * publishes exactly this graph.
16
+ */
17
+ import type { InvokeRequest, InvokeTransport } from "./types.js";
18
+ import { AgentResponseError } from "./errors.js";
19
+ import {
20
+ parseRetryAfterSeconds,
21
+ withColdStartRetry,
22
+ withSaturationRetry,
23
+ type ColdStartRetryOptions,
24
+ type SaturationRetryOptions,
25
+ } from "./saturation-retry.js";
26
+
27
+ /**
28
+ * Header carrying a caller-owned anonymous guest secret. A LOCAL MIRROR of the
29
+ * two server-side constants — the pod's `GUEST_HEADER_NAME`
30
+ * (`backend/services/nocode-runtime/src/identity.ts`) and the read plane's
31
+ * `GUEST_HEADER` (`backend/amplify/functions/publicApi/identity.ts`) — because
32
+ * this is a published npm package and cannot take a `@guuey-private` dep (same
33
+ * arrangement as `@guuey/host`'s mirrored fs-contract constants). The string is
34
+ * a wire contract: both planes already advertise it in
35
+ * `Access-Control-Allow-Headers`, so changing it is a breaking protocol change,
36
+ * not a rename.
37
+ */
38
+ export const GUEST_HEADER = "x-guuey-guest";
39
+
40
+ /**
41
+ * A well-formed guest secret: exactly 32 bytes as 64 LOWERCASE hex chars —
42
+ * the shape `crypto.getRandomValues` + hex-encoding mints.
43
+ *
44
+ * Deliberately stricter than the server's `/^[a-f0-9]{64}$/i` (pod
45
+ * `identity.ts`, publicApi `identity.ts`): both sides lowercase before
46
+ * hashing, so an uppercase secret would in fact be accepted, but the only
47
+ * supported mint path emits lowercase and a non-canonical value means the
48
+ * caller's storage is not what this adapter expects. Anything that fails is
49
+ * IGNORED — the request falls through to cookie mode rather than sending a
50
+ * secret the two identity planes might key differently.
51
+ */
52
+ const GUEST_SECRET_RE = /^[0-9a-f]{64}$/;
53
+
54
+ /**
55
+ * Narrow a caller-supplied guest secret to a value that is safe to put on the
56
+ * wire, or `null`. The single gate for the header: every write of
57
+ * {@link GUEST_HEADER} in this package goes through it, so a malformed secret
58
+ * can never reach a request. The value is never logged (here or anywhere on
59
+ * this path) — it IS the anonymous identity, so a leak is an impersonation.
60
+ */
61
+ export function sendableGuestSecret(secret: string | null | undefined): string | null {
62
+ return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
63
+ }
64
+
65
+ /**
66
+ * Is this request a cross-origin call from a browser document? Only then can
67
+ * a fetch `TypeError` be a CORS refusal worth hinting about. `location` is
68
+ * read via `typeof` so Node and React Native (where CORS does not exist)
69
+ * answer false; an unparseable URL answers false rather than throwing from
70
+ * inside error handling.
71
+ */
72
+ function isCrossOriginBrowserCall(url: string): boolean {
73
+ if (typeof location === "undefined") return false;
74
+ try {
75
+ return new URL(url).origin !== location.origin;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * One invoke attempt: opens the request and yields decoded SSE chunks.
83
+ * {@link fetchStreamTransport} wraps this with the shared saturation retry —
84
+ * every behaviour below is per-attempt.
85
+ *
86
+ * Exactly ONE identity carrier per request, in order:
87
+ *
88
+ * 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
89
+ * by their verified access token (the same identity the history read
90
+ * plane uses, so persisted threads round-trip on reload).
91
+ * 2. a well-formed `guestSecret` → `x-guuey-guest` — the caller owns and
92
+ * persists its own anonymous secret. The path for hosts with no usable
93
+ * cookie jar: React-Native, and the embedded widget, whose third-party
94
+ * iframe cannot rely on the pod's cookie surviving browser partitioning.
95
+ * The pod never mints a cookie for a header client.
96
+ * 3. neither → `credentials: "include"`, which round-trips the HttpOnly
97
+ * `guuey_guest` cookie the pod mints for anonymous browser callers.
98
+ *
99
+ * Never two at once: a bearer wins over a guest secret, and a request that
100
+ * carries either header does NOT also send cookie credentials.
101
+ *
102
+ * Reads the body via `ReadableStream.getReader()` (browser).
103
+ */
104
+ async function* streamInvokeOnce(
105
+ req: InvokeRequest,
106
+ accessToken?: string | null,
107
+ guestSecret?: string | null,
108
+ ): AsyncGenerator<string> {
109
+ const headers: Record<string, string> = {
110
+ "Content-Type": "application/json",
111
+ Accept: "text/event-stream",
112
+ };
113
+ const init: RequestInit = {
114
+ method: "POST",
115
+ signal: req.signal,
116
+ headers,
117
+ body: JSON.stringify(req.body),
118
+ };
119
+ const guest = sendableGuestSecret(guestSecret);
120
+ if (accessToken) {
121
+ headers.Authorization = `Bearer ${accessToken}`;
122
+ } else if (guest) {
123
+ headers[GUEST_HEADER] = guest;
124
+ } else {
125
+ init.credentials = "include";
126
+ }
127
+ let resp: Response;
128
+ try {
129
+ resp = await fetch(req.url, init);
130
+ } catch (err) {
131
+ // A network-level TypeError on a CROSS-ORIGIN invoke from a browser is,
132
+ // in practice, very often a missing allowedDomains entry — the CORS
133
+ // preflight failed and the web platform deliberately reports nothing
134
+ // more specific (guuey#186 Gap 2: the console.ggui.ai embed lost real
135
+ // time to an unexplained "Failed to fetch"). The error stays a
136
+ // TypeError with the original as `cause`; the added sentence is a HINT,
137
+ // not a diagnosis — offline, DNS and CSP failures throw the same shape.
138
+ // Same-origin calls and non-browser runtimes (no `location`) cannot be
139
+ // CORS refusals, so they pass through untouched.
140
+ if (err instanceof TypeError && isCrossOriginBrowserCall(req.url)) {
141
+ throw new TypeError(
142
+ `${err.message} — if this is a browser embed, check the app's allowedDomains (the CORS allowlist must include this page's origin)`,
143
+ { cause: err },
144
+ );
145
+ }
146
+ throw err;
147
+ }
148
+ if (!resp.ok || !resp.body) {
149
+ // Surface a structured pod error ({ code, message }) when present — e.g. a
150
+ // QUOTA_EXCEEDED 429 carries an upgrade message the UI should show. Fall
151
+ // back to the bare status for non-JSON failures.
152
+ const body: unknown = await resp.json().catch(() => null);
153
+ let message = `agent responded ${resp.status}`;
154
+ let code: string | undefined;
155
+ if (body !== null && typeof body === "object") {
156
+ if ("message" in body && typeof body.message === "string" && body.message) {
157
+ message = body.message;
158
+ }
159
+ if ("code" in body && typeof body.code === "string") {
160
+ code = body.code;
161
+ }
162
+ }
163
+ throw new AgentResponseError(
164
+ message,
165
+ resp.status,
166
+ code,
167
+ parseRetryAfterSeconds(resp.headers.get("Retry-After")),
168
+ );
169
+ }
170
+ const reader = resp.body.getReader();
171
+ const decoder = new TextDecoder();
172
+ for (;;) {
173
+ const { value, done } = await reader.read();
174
+ if (done) break;
175
+ yield decoder.decode(value, { stream: true });
176
+ }
177
+ }
178
+
179
+ /** Options for {@link fetchStreamTransport}. */
180
+ export interface FetchStreamTransportOptions extends SaturationRetryOptions {
181
+ /**
182
+ * Bounded retry on cold-start 503s — the envelope-less refusal an embed
183
+ * eats for ~30–60s after the agent redeploys (guuey#186 Gap 3). ON by
184
+ * default (small budget: 3 attempts, 2s/4s/8s) for parity with guuey's
185
+ * first-party embeds; pass `false` to disable, or options to re-budget.
186
+ * See {@link withColdStartRetry} for exactly what matches (and what
187
+ * deliberately stays with the saturation policy instead).
188
+ */
189
+ coldStartRetry?: ColdStartRetryOptions | false;
190
+ /**
191
+ * Injectable bearer provider (guuey#186 Gap 4) — identity is a transport
192
+ * concern (see {@link InvokeTransport}: "owns headers + identity
193
+ * entirely"), and a harness or non-React host holds credentials in its own
194
+ * lifecycle, not in a closure minted once at page load. Resolved PER
195
+ * ATTEMPT, before each request — a retry after a backoff wait re-reads it,
196
+ * so a token that expired during the wait is refreshed rather than
197
+ * replayed. When present it takes precedence over the positional
198
+ * `accessToken`; resolving `null` falls through to the guest secret /
199
+ * cookie chain exactly as a null `accessToken` does (and carries the same
200
+ * silent-anonymous-downgrade hazard the `createWebAdapters` docs warn
201
+ * about). A throw propagates and fails the invoke — deliberately not
202
+ * caught, for the same reason as `getGuestSecret` there.
203
+ */
204
+ getBearer?: () => string | null | Promise<string | null>;
205
+ }
206
+
207
+ /**
208
+ * The web SSE transport: {@link streamInvokeOnce} under the shared
209
+ * {@link withSaturationRetry} wrapper, itself under {@link withColdStartRetry}.
210
+ * Every consumer of this transport (Studio, the widget, anything built on
211
+ * `createWebAdapters`) therefore inherits the single `POD_SATURATED` retry AND
212
+ * the bounded cold-start 503 retry, the same pair Portal's React-Native
213
+ * transport wears — see the wrappers' docblocks for which refusals retry,
214
+ * which deliberately do not, and why both retries are invisible to the hook.
215
+ * Both wrappers guard on "nothing yielded yet": once a chunk has streamed,
216
+ * NOTHING re-POSTs.
217
+ */
218
+ export function fetchStreamTransport(
219
+ req: InvokeRequest,
220
+ accessToken?: string | null,
221
+ guestSecret?: string | null,
222
+ options: FetchStreamTransportOptions = {},
223
+ ): AsyncIterable<string> {
224
+ const { getBearer } = options;
225
+ const once = async function* (attempt: InvokeRequest): AsyncGenerator<string> {
226
+ // Per-attempt resolution: each retry re-asks the provider (fresh token
227
+ // after a backoff wait) instead of replaying a captured one.
228
+ const bearer = getBearer ? await getBearer() : accessToken;
229
+ yield* streamInvokeOnce(attempt, bearer, guestSecret);
230
+ };
231
+ const saturated = withSaturationRetry(once, { sleep: options.sleep });
232
+ if (options.coldStartRetry === false) return saturated(req);
233
+ return withColdStartRetry(saturated, {
234
+ sleep: options.sleep,
235
+ ...options.coldStartRetry,
236
+ })(req);
237
+ }
238
+
239
+ /**
240
+ * Wrap a transport so every yielded chunk ALSO pings `onChunk` — the
241
+ * byte-level liveness signal `useAgentInvoke`'s stall watchdog runs on
242
+ * (guuey#192). Purely observational: chunks pass through unchanged, errors
243
+ * and completion propagate untouched, and the wrapper adds no timers of its
244
+ * own — the OBSERVER owns the clock, this module only reports activity. The
245
+ * first ping doubles as the "first byte seen" arming signal, which is why
246
+ * the watchdog never fires during a silent cold start: no bytes, no ping,
247
+ * no armed timer (that phase belongs to {@link withColdStartRetry} and the
248
+ * user's own abort).
249
+ */
250
+ export function withActivityObserver(
251
+ transport: InvokeTransport,
252
+ onChunk: () => void,
253
+ ): InvokeTransport {
254
+ return async function* observed(req: InvokeRequest): AsyncGenerator<string> {
255
+ for await (const chunk of transport(req)) {
256
+ onChunk();
257
+ yield chunk;
258
+ }
259
+ };
260
+ }
package/src/types.ts CHANGED
@@ -19,6 +19,13 @@ import type { AgReduceResult, JsonValue } from "@silverprotocol/core";
19
19
  export interface AgentMessage {
20
20
  role: "user" | "assistant";
21
21
  text: string;
22
+ /**
23
+ * The invoke body's `clientMessageId`, present on user turns this client
24
+ * sent live (the optimistic push tags it). History-rehydrated entries and
25
+ * assistant turns omit it. The transcript renderer's R0 send-lifecycle
26
+ * join key (`UseAgentInvokeReturn.sendStates`).
27
+ */
28
+ clientMessageId?: string;
22
29
  }
23
30
 
24
31
  /**
@@ -127,6 +134,23 @@ export interface AgentInvokeAdapters {
127
134
  history?: AgentInvokeHistoryAdapter;
128
135
  }
129
136
 
137
+ /** Tuning for the guuey#192 stall watchdog — see {@link UseAgentInvokeOptions.stallRecovery}. */
138
+ export interface StallRecoveryOptions {
139
+ /**
140
+ * Byte-inactivity window (ms) before a history probe fires. Armed only
141
+ * AFTER the first byte of the turn — pre-first-byte silence is a legitimate
142
+ * cold start and never triggers. Every received chunk resets it.
143
+ * Default 25000.
144
+ */
145
+ windowMs?: number;
146
+ /**
147
+ * Fruitless probes (history shows the turn still in flight, or no probe is
148
+ * possible) before the turn fails with `CLIENT_ERROR_CODES.STREAM_STALLED`.
149
+ * Any received byte resets the count. Default 4.
150
+ */
151
+ probeAttempts?: number;
152
+ }
153
+
130
154
  export interface UseAgentInvokeOptions {
131
155
  /** Pod base URL (with or without a trailing `/agent/invoke`). Chat is disabled when null. */
132
156
  endpointUrl: string | null;
@@ -141,6 +165,29 @@ export interface UseAgentInvokeOptions {
141
165
  * reducer is never constructed and the text behaviour is byte-identical.
142
166
  */
143
167
  preserveBlocks?: boolean;
168
+ /**
169
+ * Stall recovery for a half-dead stream (guuey#192). A connection that dies
170
+ * WITHOUT erroring (TCP alive, zero bytes, no `done`) would otherwise leave
171
+ * the turn suspended forever — a frozen cursor while the backend may have
172
+ * completed and persisted the reply. On by default; `false` restores the
173
+ * old hang-forever behaviour; an object tunes the window/attempts. Armed
174
+ * only after the first byte; probes are non-destructive (they read history
175
+ * WITHOUT touching the live stream) and adopt the finished reply from the
176
+ * history adapter when it is already persisted — see the watchdog notes in
177
+ * `useAgentInvoke`.
178
+ */
179
+ stallRecovery?: false | StallRecoveryOptions;
180
+ /**
181
+ * Optional external abort authority (guuey#186 Gap 4): a host lifecycle —
182
+ * route change, dialog close, screen unmount — that must be able to stop
183
+ * an in-flight turn without holding the hook's own `abort()`. Composed
184
+ * with (never replacing) the hook's internal per-turn controller: when
185
+ * this signal aborts, the current turn aborts exactly as `abort()` would;
186
+ * the hook's `abort()`/`reset()` keep working regardless. Already-aborted
187
+ * at send time means the send is refused before any request is made. The
188
+ * signal may span many turns — each send observes its CURRENT state.
189
+ */
190
+ signal?: AbortSignal;
144
191
  }
145
192
 
146
193
  /**
@@ -197,7 +244,9 @@ export interface UseAgentInvokeReturn {
197
244
  *
198
245
  * It is a `string`, not the `AgentErrorCode` union: the pod may ship a new
199
246
  * code before a consumer upgrades this SDK, and a narrowed type would make
200
- * that unrepresentable rather than merely unhandled.
247
+ * that unrepresentable rather than merely unhandled. ONE code is
248
+ * client-originated rather than a pod wire code: `STREAM_STALLED` (see
249
+ * `CLIENT_ERROR_CODES`), set when the guuey#192 stall watchdog gives up.
201
250
  */
202
251
  errorCode: string | null;
203
252
  threadId: string | null;
@@ -258,4 +307,28 @@ export interface UseAgentInvokeReturn {
258
307
  profileLinkRequest: ProfileLinkRequest | null;
259
308
  /** Dismiss the pending {@link profileLinkRequest} (back to `null`). */
260
309
  clearProfileLinkRequest: () => void;
310
+ /**
311
+ * The LAST turn ended by user abort (`abort()` or the external signal) —
312
+ * the transcript renderer's R1 aborted-partial + "Stopped." signal.
313
+ * Distinct from the #192 watchdog's internal stream abort, which never
314
+ * sets it. Cleared by the next `send()` and by `reset()`.
315
+ */
316
+ aborted: boolean;
317
+ /**
318
+ * The LAST turn was ADOPTED from persisted history by the guuey#192 stall
319
+ * watchdog (the reply was already durably written; the dead stream was
320
+ * discarded). Presentation contract: calm surfaces render an adopted turn
321
+ * identically to a streamed one; debug surfaces may mark it. Cleared by
322
+ * the next `send()` and by `reset()`.
323
+ */
324
+ adopted: boolean;
325
+ /**
326
+ * The optimistic-send lifecycle, keyed by each live user turn's
327
+ * `clientMessageId` (see {@link AgentMessage.clientMessageId}):
328
+ * `"sending"` until the pod's `session` frame admits the turn, `"failed"`
329
+ * when the turn errored BEFORE admission (the message never reached the
330
+ * agent — the R0 failed-to-send state with its retry affordance). Entries
331
+ * for admitted turns are removed (absent = sent). `reset()` clears it.
332
+ */
333
+ sendStates: Readonly<Record<string, "sending" | "failed">>;
261
334
  }