@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
@@ -0,0 +1,124 @@
1
+ import { parseConsentRequest, parseLinkRequest, parseSseEvents, reduceAssistantText, stringField, } from "./sse.js";
2
+ import { ingestMessageFrame } from "./blocks.js";
3
+ /**
4
+ * Normalize an agent endpoint to its invoke URL (guuey#186 G3). Accepts BOTH
5
+ * shapes a consumer legitimately holds — a pod base (`https://host`) and the
6
+ * full invoke URL the deploy-controller records (`https://host/agent/invoke`)
7
+ * — and returns exactly one `/agent/invoke`, trailing slashes dropped. This
8
+ * is the single normalization `useAgentInvoke` applies to its `endpointUrl`;
9
+ * a host driving {@link invokeTurn} (or any raw transport) directly builds
10
+ * its request URL with the same call instead of re-implementing the rule.
11
+ */
12
+ export function toInvokeUrl(endpointUrl) {
13
+ const base = endpointUrl.replace(/\/+$/, "");
14
+ return base.endsWith("/agent/invoke") ? base : `${base}/agent/invoke`;
15
+ }
16
+ /**
17
+ * Drive one `/agent/invoke` turn over `transport`, yielding semantic events.
18
+ * Pure per-turn: no React, no storage, no retry policy (the transport owns
19
+ * saturation retry), no reducer — see the module docblock for what belongs
20
+ * to the caller.
21
+ *
22
+ * The event stream is also the OBSERVATION channel (guuey#186 Gap 4): there
23
+ * is deliberately no `onToolResult` callback API, because filtering the
24
+ * generator expresses it directly — every tool result arrives as a typed
25
+ * `tool.done` AgEvent on a `message` event, carrying `toolCallId`,
26
+ * `content`, `outcome` and `structuredContent`.
27
+ *
28
+ * @example Telemetry off the fold — observe tool results without touching
29
+ * the transcript path:
30
+ * ```ts
31
+ * for await (const ev of invokeTurn(req, transport)) {
32
+ * if (ev.kind !== "message") continue;
33
+ * for (const agEvent of ev.agEvents) {
34
+ * if (agEvent.type === "tool.done") {
35
+ * telemetry.record(agEvent.toolCallId, agEvent.outcome ?? "ok");
36
+ * }
37
+ * }
38
+ * render(ev.assistantText); // the fold is untouched by the observation
39
+ * }
40
+ * ```
41
+ */
42
+ export async function* invokeTurn(req, transport) {
43
+ let assistantText = "";
44
+ let buffer = "";
45
+ for await (const chunk of transport(req)) {
46
+ buffer += chunk;
47
+ const { events, rest } = parseSseEvents(buffer);
48
+ buffer = rest;
49
+ for (const ev of events) {
50
+ if (ev.event === "session") {
51
+ // The pod is awake and the turn is admitted (this frame arrives
52
+ // within ~1s of a warm pod; a cold scale-to-zero start is exactly
53
+ // the long wait before it).
54
+ yield { kind: "session", threadId: stringField(ev.data, "threadId") ?? null };
55
+ }
56
+ else if (ev.event === "message") {
57
+ // Status derivation (guuey#91) — read the frame's `type` before the
58
+ // text fold. Silver frames announce tools + text explicitly; bypass
59
+ // frames ('text' / 'assistant' SDKMessages) only ever carry
60
+ // assistant text, so they map to 'responding'. Unknown types
61
+ // deliberately imply no status change.
62
+ const frameType = stringField(ev.data, "type");
63
+ assistantText = reduceAssistantText(assistantText, ev.data);
64
+ // Only VALID AgEvents surface (bypass frames ingest to []) — the
65
+ // caller's reducer, if any, advances on these alone.
66
+ const agEvents = ingestMessageFrame(ev.data);
67
+ if (frameType === "tool.start") {
68
+ yield {
69
+ kind: "message",
70
+ status: "using-tool",
71
+ activeTool: stringField(ev.data, "name") ?? null,
72
+ assistantText,
73
+ agEvents,
74
+ };
75
+ }
76
+ else if (frameType === "tool.done") {
77
+ yield { kind: "message", status: "thinking", activeTool: null, assistantText, agEvents };
78
+ }
79
+ else if (frameType === "text.start" ||
80
+ frameType === "text.delta" ||
81
+ frameType === "text" ||
82
+ frameType === "assistant") {
83
+ yield { kind: "message", status: "responding", assistantText, agEvents };
84
+ }
85
+ else {
86
+ yield { kind: "message", assistantText, agEvents };
87
+ }
88
+ }
89
+ else if (ev.event === "error") {
90
+ // In-band failure frame — one of the two channels that carry the
91
+ // pod's wire code (the other is the pre-stream refusal thrown by the
92
+ // transport). A frame without a `code` yields null rather than
93
+ // leaving a previous turn's code standing beside a new message.
94
+ yield {
95
+ kind: "error",
96
+ message: stringField(ev.data, "message") ?? "agent error",
97
+ code: stringField(ev.data, "code") ?? null,
98
+ };
99
+ }
100
+ else if (ev.event === "profile-consent-needed") {
101
+ // Cross-app profile consent ask (T6). Only a well-formed payload
102
+ // yields; a malformed one is dropped, leaving any prior valid
103
+ // request untouched (never clobbered to null).
104
+ const parsed = parseConsentRequest(ev.data);
105
+ if (parsed)
106
+ yield { kind: "profile-consent", request: parsed };
107
+ }
108
+ else if (ev.event === "profile-link-needed") {
109
+ // Cross-app profile LINK invite (linkcoh T3) for an unlinked byo
110
+ // caller. Same drop-if-malformed contract as consent above.
111
+ const parsed = parseLinkRequest(ev.data);
112
+ if (parsed)
113
+ yield { kind: "profile-link", request: parsed };
114
+ }
115
+ else if (ev.event === "done") {
116
+ // The stream closes after this frame; yielded so a host can read the
117
+ // pod's stop reason without private wire knowledge.
118
+ yield { kind: "done", stopReason: stringField(ev.data, "stopReason") ?? null };
119
+ }
120
+ // Any other (unknown) event falls through silently — additive wire
121
+ // events never disturb a consumer.
122
+ }
123
+ }
124
+ }
package/dist/react.d.ts CHANGED
@@ -6,6 +6,6 @@
6
6
  * history reader, and the web adapters). Consumers that only need those never
7
7
  * import React at all; consumers that render chat import the hook from here.
8
8
  */
9
- export { useAgentInvoke, applyHistoryResult, type HistoryApplication } from "./useAgentInvoke";
9
+ export { useAgentInvoke, applyHistoryResult, type HistoryApplication, stallProbeDecision, STALL_RECOVERY_DEFAULTS, } from "./useAgentInvoke.js";
10
10
  export type { AgEvent, AgReduceResult } from "@silverprotocol/core";
11
11
  //# sourceMappingURL=react.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAI/F,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,qBAAqB,CAAC;AAI7B,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"}
package/dist/react.js CHANGED
@@ -6,4 +6,4 @@
6
6
  * history reader, and the web adapters). Consumers that only need those never
7
7
  * import React at all; consumers that render chat import the hook from here.
8
8
  */
9
- export { useAgentInvoke, applyHistoryResult } from "./useAgentInvoke";
9
+ export { useAgentInvoke, applyHistoryResult, stallProbeDecision, STALL_RECOVERY_DEFAULTS, } from "./useAgentInvoke.js";
@@ -0,0 +1,101 @@
1
+ import type { InvokeTransport } from "./types.js";
2
+ /**
3
+ * Read `Retry-After` as WHOLE SECONDS, or `undefined`.
4
+ *
5
+ * HTTP also allows an absolute HTTP-date, which is deliberately NOT parsed:
6
+ * the pod only ever emits a delta-seconds integer, and silently mis-reading a
7
+ * date as `NaN` seconds is worse than falling back to the fixed delay.
8
+ *
9
+ * Exported because every transport that builds an {@link AgentResponseError}
10
+ * has to fill `retryAfterSeconds` the same way for {@link withSaturationRetry}
11
+ * to honour the same hint — a second hand-rolled regex in a host adapter is
12
+ * exactly the drift this module exists to prevent.
13
+ */
14
+ export declare function parseRetryAfterSeconds(header: string | null): number | undefined;
15
+ /** Options for {@link withSaturationRetry}. */
16
+ export interface SaturationRetryOptions {
17
+ /**
18
+ * The saturation-retry wait. Injectable so tests drive the retry without a
19
+ * real 15s timer; production uses an abort-aware `setTimeout`.
20
+ */
21
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
22
+ }
23
+ /**
24
+ * Wrap an invoke transport with ONE automatic retry on a saturated pod.
25
+ *
26
+ * ## What retries, and what deliberately does not
27
+ *
28
+ * `POD_SATURATED` (503) means the pod is at its concurrent-turn cap right now
29
+ * — a transient queue state that clears as in-flight turns finish, so a single
30
+ * delayed re-send usually just works. The wait is the pod's own `Retry-After`
31
+ * hint (via {@link AgentResponseError.retryAfterSeconds}), defaulting to 15s
32
+ * when it sent none and capped at 30s.
33
+ *
34
+ * `DRAINING` (also 503 + `Retry-After`) is NOT retried in v1. The refusing pod
35
+ * is shutting down: its readiness probe is already failing and the endpoint
36
+ * pull is in flight, so the useful retry is the one that reaches a DIFFERENT
37
+ * pod — and a wrapped transport re-sends to the same URL. Retrying here would
38
+ * spend the user's 15s to arrive back at the same draining pod (or at a fresh
39
+ * one by luck), which is not a guarantee worth building on. When the retry can
40
+ * be made routing-aware, this is the code to revisit.
41
+ *
42
+ * Exactly ONE retry: a second saturation propagates as
43
+ * {@link AgentResponseError}, so a genuinely overloaded agent surfaces instead
44
+ * of looping. Nothing is retried once a chunk has been yielded — replaying
45
+ * mid-stream would duplicate a partial assistant turn (the same `yielded`
46
+ * guard the widget's `withIdentifiedToken` 401-retry uses). An abort during
47
+ * the wait skips the retry and surfaces the original refusal.
48
+ *
49
+ * The retry is INVISIBLE to `useAgentInvoke`: no frames were yielded, so the
50
+ * turn simply stays in `connecting` for the duration of the wait. There is no
51
+ * `retrying` status by design — the hook's state machine describes the pod's
52
+ * turn lifecycle, not the transport's plumbing.
53
+ *
54
+ * The wrapped transport is re-invoked from scratch for the retry, so a host
55
+ * that resolves identity inside its own generator (Portal's RN transport reads
56
+ * the bearer bridge per attempt) re-reads it on the second try rather than
57
+ * replaying a token that may have expired during the wait.
58
+ */
59
+ export declare function withSaturationRetry(transport: InvokeTransport, options?: SaturationRetryOptions): InvokeTransport;
60
+ /** Options for {@link withColdStartRetry}. */
61
+ export interface ColdStartRetryOptions {
62
+ /**
63
+ * Retries after the initial attempt (`0` disables the wrapper's behaviour
64
+ * entirely). Default 3 — a small, bounded budget: the point is parity with
65
+ * guuey's first-party embeds during the ordinary post-redeploy window, not
66
+ * riding out an outage. Raise it for an unattended harness that would
67
+ * rather wait than fail.
68
+ */
69
+ attempts?: number;
70
+ /**
71
+ * First wait in ms; each subsequent wait doubles, capped at
72
+ * {@link maxDelayMs}. Default 2000 → 2s / 4s / 8s for the default budget.
73
+ */
74
+ baseDelayMs?: number;
75
+ /** Ceiling on any single wait (hinted or computed), in ms. Default 10000. */
76
+ maxDelayMs?: number;
77
+ /** The wait itself — injectable so tests drive the retry without timers. */
78
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
79
+ }
80
+ /**
81
+ * Wrap an invoke transport with a bounded retry on cold-start 503s
82
+ * (guuey#186 Gap 3 — parity with first-party embeds, which already carry
83
+ * this behaviour; SDK consumers were eating the raw 503 window instead).
84
+ *
85
+ * Matches ONLY {@link isColdStartRefusal} — an envelope-less 503 — and
86
+ * retries up to `attempts` times with doubling, capped backoff (honouring a
87
+ * `Retry-After` hint when the response carried one). Exhaustion propagates
88
+ * the final refusal untouched.
89
+ *
90
+ * Nothing is retried once a chunk has been yielded: a stream that dies
91
+ * MID-turn is never silently re-POSTed — the turn may have had side effects
92
+ * and the consumer already saw partial output. Same `yielded` guard as
93
+ * {@link withSaturationRetry}, same reasoning. An abort during a wait
94
+ * surfaces the refusal that caused the wait.
95
+ *
96
+ * Like the saturation wrapper, the retry is invisible to `useAgentInvoke`
97
+ * (the turn stays in `connecting`), and the wrapped transport is re-invoked
98
+ * from scratch so per-attempt identity resolution re-runs.
99
+ */
100
+ export declare function withColdStartRetry(transport: InvokeTransport, options?: ColdStartRetryOptions): InvokeTransport;
101
+ //# sourceMappingURL=saturation-retry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saturation-retry.d.ts","sourceRoot":"","sources":["../src/saturation-retry.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAiB,eAAe,EAAE,MAAM,YAAY,CAAC;AAiBjE;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAMhF;AA4BD,+CAA+C;AAC/C,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,eAAe,EAC1B,OAAO,GAAE,sBAA2B,GACnC,eAAe,CAqBjB;AAED,8CAA8C;AAC9C,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAkBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,eAAe,EAC1B,OAAO,GAAE,qBAA0B,GAClC,eAAe,CA0BjB"}
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The invoke-refusal retry wrappers, transport-agnostic: the single
3
+ * `POD_SATURATED` auto-retry ({@link withSaturationRetry}) and the bounded
4
+ * cold-start 503 retry ({@link withColdStartRetry}).
5
+ *
6
+ * Their own module — rather than living inside `./web-adapters.ts`, where the
7
+ * first was born — because the behaviour is a property of the platform's
8
+ * refusal vocabulary, not of `fetch`. Every host that speaks `/agent/invoke`
9
+ * wants them, including the ones that cannot import the web adapter bundle:
10
+ * Portal's React-Native transport wraps its own `fetch` call with these the
11
+ * same way `fetchStreamTransport` wraps its browser streaming reader, so the
12
+ * two wear byte-identical retry semantics instead of two hand-written copies
13
+ * that drift.
14
+ *
15
+ * The two wrappers are DELIBERATELY distinct code paths: saturation retry is
16
+ * driven by the pod's structured refusal envelope (`code: POD_SATURATED`),
17
+ * while the cold-start retry matches only an envelope-LESS 503 — the raw
18
+ * infra answer (ingress with no ready pod) during the post-redeploy window,
19
+ * which by definition carries no wire code. They share the backoff machinery,
20
+ * never the predicate.
21
+ *
22
+ * This module imports only `./types.js`, `./errors.js` and `./error-codes.js`
23
+ * — all pure — so pulling it in costs a React-Native build nothing.
24
+ */
25
+ import { AGENT_ERROR_CODES } from "./error-codes.js";
26
+ import { AgentResponseError } from "./errors.js";
27
+ /**
28
+ * Fallback wait before the saturation retry when the pod sent no usable
29
+ * `Retry-After` — the same 15s the pod's governor hints today
30
+ * (`GOVERNOR_RETRY_AFTER_SECONDS`), so a stripped header behaves like the
31
+ * normal case rather than hammering.
32
+ */
33
+ const SATURATION_FALLBACK_DELAY_SECONDS = 15;
34
+ /**
35
+ * Ceiling on the honoured hint. A pod that (mis)configures a multi-minute
36
+ * `Retry-After` must not park a chat UI in `connecting` for that long — past
37
+ * this the user is better served by the visible failure they can act on.
38
+ */
39
+ const SATURATION_MAX_DELAY_SECONDS = 30;
40
+ /**
41
+ * Read `Retry-After` as WHOLE SECONDS, or `undefined`.
42
+ *
43
+ * HTTP also allows an absolute HTTP-date, which is deliberately NOT parsed:
44
+ * the pod only ever emits a delta-seconds integer, and silently mis-reading a
45
+ * date as `NaN` seconds is worse than falling back to the fixed delay.
46
+ *
47
+ * Exported because every transport that builds an {@link AgentResponseError}
48
+ * has to fill `retryAfterSeconds` the same way for {@link withSaturationRetry}
49
+ * to honour the same hint — a second hand-rolled regex in a host adapter is
50
+ * exactly the drift this module exists to prevent.
51
+ */
52
+ export function parseRetryAfterSeconds(header) {
53
+ if (header === null)
54
+ return undefined;
55
+ const trimmed = header.trim();
56
+ if (!/^\d+$/.test(trimmed))
57
+ return undefined;
58
+ const seconds = Number(trimmed);
59
+ return Number.isSafeInteger(seconds) ? seconds : undefined;
60
+ }
61
+ /** How long to wait before the single saturation retry, in milliseconds. */
62
+ function saturationDelayMs(retryAfterSeconds) {
63
+ const hinted = retryAfterSeconds ?? SATURATION_FALLBACK_DELAY_SECONDS;
64
+ return Math.min(hinted, SATURATION_MAX_DELAY_SECONDS) * 1000;
65
+ }
66
+ /**
67
+ * Wait `ms`, or resolve early if the turn is aborted — a user who hits stop
68
+ * must not sit through the remainder of a 15s backoff before the UI settles.
69
+ */
70
+ function delay(ms, signal) {
71
+ return new Promise((resolve) => {
72
+ if (signal.aborted) {
73
+ resolve();
74
+ return;
75
+ }
76
+ const finish = () => {
77
+ clearTimeout(timer);
78
+ signal.removeEventListener("abort", finish);
79
+ resolve();
80
+ };
81
+ const timer = setTimeout(finish, ms);
82
+ signal.addEventListener("abort", finish, { once: true });
83
+ });
84
+ }
85
+ /**
86
+ * Wrap an invoke transport with ONE automatic retry on a saturated pod.
87
+ *
88
+ * ## What retries, and what deliberately does not
89
+ *
90
+ * `POD_SATURATED` (503) means the pod is at its concurrent-turn cap right now
91
+ * — a transient queue state that clears as in-flight turns finish, so a single
92
+ * delayed re-send usually just works. The wait is the pod's own `Retry-After`
93
+ * hint (via {@link AgentResponseError.retryAfterSeconds}), defaulting to 15s
94
+ * when it sent none and capped at 30s.
95
+ *
96
+ * `DRAINING` (also 503 + `Retry-After`) is NOT retried in v1. The refusing pod
97
+ * is shutting down: its readiness probe is already failing and the endpoint
98
+ * pull is in flight, so the useful retry is the one that reaches a DIFFERENT
99
+ * pod — and a wrapped transport re-sends to the same URL. Retrying here would
100
+ * spend the user's 15s to arrive back at the same draining pod (or at a fresh
101
+ * one by luck), which is not a guarantee worth building on. When the retry can
102
+ * be made routing-aware, this is the code to revisit.
103
+ *
104
+ * Exactly ONE retry: a second saturation propagates as
105
+ * {@link AgentResponseError}, so a genuinely overloaded agent surfaces instead
106
+ * of looping. Nothing is retried once a chunk has been yielded — replaying
107
+ * mid-stream would duplicate a partial assistant turn (the same `yielded`
108
+ * guard the widget's `withIdentifiedToken` 401-retry uses). An abort during
109
+ * the wait skips the retry and surfaces the original refusal.
110
+ *
111
+ * The retry is INVISIBLE to `useAgentInvoke`: no frames were yielded, so the
112
+ * turn simply stays in `connecting` for the duration of the wait. There is no
113
+ * `retrying` status by design — the hook's state machine describes the pod's
114
+ * turn lifecycle, not the transport's plumbing.
115
+ *
116
+ * The wrapped transport is re-invoked from scratch for the retry, so a host
117
+ * that resolves identity inside its own generator (Portal's RN transport reads
118
+ * the bearer bridge per attempt) re-reads it on the second try rather than
119
+ * replaying a token that may have expired during the wait.
120
+ */
121
+ export function withSaturationRetry(transport, options = {}) {
122
+ return async function* retrying(req) {
123
+ let yielded = false;
124
+ try {
125
+ for await (const chunk of transport(req)) {
126
+ yielded = true;
127
+ yield chunk;
128
+ }
129
+ return;
130
+ }
131
+ catch (err) {
132
+ const saturated = err instanceof AgentResponseError && err.code === AGENT_ERROR_CODES.POD_SATURATED;
133
+ if (!saturated || yielded)
134
+ throw err;
135
+ await (options.sleep ?? delay)(saturationDelayMs(err.retryAfterSeconds), req.signal);
136
+ // Aborted mid-wait: the user is done with this turn. Surface the refusal
137
+ // that caused the wait rather than spending a request that `fetch` would
138
+ // reject on the signal anyway.
139
+ if (req.signal.aborted)
140
+ throw err;
141
+ }
142
+ yield* transport(req);
143
+ };
144
+ }
145
+ const COLD_START_DEFAULT_ATTEMPTS = 3;
146
+ const COLD_START_BASE_DELAY_MS = 2_000;
147
+ const COLD_START_MAX_DELAY_MS = 10_000;
148
+ /**
149
+ * Is this failure the cold-start refusal shape? An envelope-less 503: the
150
+ * status came from infra (no ready pod behind the route — the ~30–60s window
151
+ * after a redeploy), so there is no wire `code`. A 503 that DOES carry a code
152
+ * is the pod itself refusing (`POD_SATURATED`, `DRAINING`) and belongs to
153
+ * {@link withSaturationRetry}'s policy — including its deliberate decision NOT
154
+ * to retry `DRAINING` — never to this wrapper.
155
+ */
156
+ function isColdStartRefusal(err) {
157
+ return err instanceof AgentResponseError && err.status === 503 && err.code === undefined;
158
+ }
159
+ /**
160
+ * Wrap an invoke transport with a bounded retry on cold-start 503s
161
+ * (guuey#186 Gap 3 — parity with first-party embeds, which already carry
162
+ * this behaviour; SDK consumers were eating the raw 503 window instead).
163
+ *
164
+ * Matches ONLY {@link isColdStartRefusal} — an envelope-less 503 — and
165
+ * retries up to `attempts` times with doubling, capped backoff (honouring a
166
+ * `Retry-After` hint when the response carried one). Exhaustion propagates
167
+ * the final refusal untouched.
168
+ *
169
+ * Nothing is retried once a chunk has been yielded: a stream that dies
170
+ * MID-turn is never silently re-POSTed — the turn may have had side effects
171
+ * and the consumer already saw partial output. Same `yielded` guard as
172
+ * {@link withSaturationRetry}, same reasoning. An abort during a wait
173
+ * surfaces the refusal that caused the wait.
174
+ *
175
+ * Like the saturation wrapper, the retry is invisible to `useAgentInvoke`
176
+ * (the turn stays in `connecting`), and the wrapped transport is re-invoked
177
+ * from scratch so per-attempt identity resolution re-runs.
178
+ */
179
+ export function withColdStartRetry(transport, options = {}) {
180
+ const attempts = options.attempts ?? COLD_START_DEFAULT_ATTEMPTS;
181
+ const baseDelayMs = options.baseDelayMs ?? COLD_START_BASE_DELAY_MS;
182
+ const maxDelayMs = options.maxDelayMs ?? COLD_START_MAX_DELAY_MS;
183
+ const sleep = options.sleep ?? delay;
184
+ return async function* retrying(req) {
185
+ let yielded = false;
186
+ for (let attempt = 0;; attempt++) {
187
+ try {
188
+ for await (const chunk of transport(req)) {
189
+ yielded = true;
190
+ yield chunk;
191
+ }
192
+ return;
193
+ }
194
+ catch (err) {
195
+ if (!isColdStartRefusal(err) || yielded || attempt >= attempts)
196
+ throw err;
197
+ const hintedMs = err.retryAfterSeconds !== undefined ? err.retryAfterSeconds * 1000 : undefined;
198
+ const waitMs = Math.min(hintedMs ?? baseDelayMs * 2 ** attempt, maxDelayMs);
199
+ await sleep(waitMs, req.signal);
200
+ // Aborted mid-wait: surface the refusal that caused the wait rather
201
+ // than spending a request that `fetch` would reject on the signal.
202
+ if (req.signal.aborted)
203
+ throw err;
204
+ }
205
+ }
206
+ };
207
+ }
package/dist/sse.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * platform dependencies — unit-tested in isolation (`sse.test.ts`) and shared
4
4
  * verbatim across web (Studio) and React-Native (Portal).
5
5
  */
6
- import type { ProfileConsentRequest, ProfileLinkRequest } from "./types";
6
+ import type { ProfileConsentRequest, ProfileLinkRequest } from "./types.js";
7
7
  export interface ParsedSseEvent {
8
8
  event: string;
9
9
  data: unknown;
package/dist/sse.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAEzE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAqBzF;AAsBD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAU1E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAmC1D;AAED,kEAAkE;AAClE,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAI1E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,qBAAqB,GAAG,IAAI,CAO/E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,kBAAkB,GAAG,IAAI,CAOzE"}
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAE5E,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAqBzF;AAsBD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAU1E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAmC1D;AAED,kEAAkE;AAClE,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAI1E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,qBAAqB,GAAG,IAAI,CAO/E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,kBAAkB,GAAG,IAAI,CAOzE"}
@@ -0,0 +1,90 @@
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 { type ColdStartRetryOptions, type SaturationRetryOptions } from "./saturation-retry.js";
19
+ /**
20
+ * Header carrying a caller-owned anonymous guest secret. A LOCAL MIRROR of the
21
+ * two server-side constants — the pod's `GUEST_HEADER_NAME`
22
+ * (`backend/services/nocode-runtime/src/identity.ts`) and the read plane's
23
+ * `GUEST_HEADER` (`backend/amplify/functions/publicApi/identity.ts`) — because
24
+ * this is a published npm package and cannot take a `@guuey-private` dep (same
25
+ * arrangement as `@guuey/host`'s mirrored fs-contract constants). The string is
26
+ * a wire contract: both planes already advertise it in
27
+ * `Access-Control-Allow-Headers`, so changing it is a breaking protocol change,
28
+ * not a rename.
29
+ */
30
+ export declare const GUEST_HEADER = "x-guuey-guest";
31
+ /**
32
+ * Narrow a caller-supplied guest secret to a value that is safe to put on the
33
+ * wire, or `null`. The single gate for the header: every write of
34
+ * {@link GUEST_HEADER} in this package goes through it, so a malformed secret
35
+ * can never reach a request. The value is never logged (here or anywhere on
36
+ * this path) — it IS the anonymous identity, so a leak is an impersonation.
37
+ */
38
+ export declare function sendableGuestSecret(secret: string | null | undefined): string | null;
39
+ /** Options for {@link fetchStreamTransport}. */
40
+ export interface FetchStreamTransportOptions extends SaturationRetryOptions {
41
+ /**
42
+ * Bounded retry on cold-start 503s — the envelope-less refusal an embed
43
+ * eats for ~30–60s after the agent redeploys (guuey#186 Gap 3). ON by
44
+ * default (small budget: 3 attempts, 2s/4s/8s) for parity with guuey's
45
+ * first-party embeds; pass `false` to disable, or options to re-budget.
46
+ * See {@link withColdStartRetry} for exactly what matches (and what
47
+ * deliberately stays with the saturation policy instead).
48
+ */
49
+ coldStartRetry?: ColdStartRetryOptions | false;
50
+ /**
51
+ * Injectable bearer provider (guuey#186 Gap 4) — identity is a transport
52
+ * concern (see {@link InvokeTransport}: "owns headers + identity
53
+ * entirely"), and a harness or non-React host holds credentials in its own
54
+ * lifecycle, not in a closure minted once at page load. Resolved PER
55
+ * ATTEMPT, before each request — a retry after a backoff wait re-reads it,
56
+ * so a token that expired during the wait is refreshed rather than
57
+ * replayed. When present it takes precedence over the positional
58
+ * `accessToken`; resolving `null` falls through to the guest secret /
59
+ * cookie chain exactly as a null `accessToken` does (and carries the same
60
+ * silent-anonymous-downgrade hazard the `createWebAdapters` docs warn
61
+ * about). A throw propagates and fails the invoke — deliberately not
62
+ * caught, for the same reason as `getGuestSecret` there.
63
+ */
64
+ getBearer?: () => string | null | Promise<string | null>;
65
+ }
66
+ /**
67
+ * The web SSE transport: {@link streamInvokeOnce} under the shared
68
+ * {@link withSaturationRetry} wrapper, itself under {@link withColdStartRetry}.
69
+ * Every consumer of this transport (Studio, the widget, anything built on
70
+ * `createWebAdapters`) therefore inherits the single `POD_SATURATED` retry AND
71
+ * the bounded cold-start 503 retry, the same pair Portal's React-Native
72
+ * transport wears — see the wrappers' docblocks for which refusals retry,
73
+ * which deliberately do not, and why both retries are invisible to the hook.
74
+ * Both wrappers guard on "nothing yielded yet": once a chunk has streamed,
75
+ * NOTHING re-POSTs.
76
+ */
77
+ export declare function fetchStreamTransport(req: InvokeRequest, accessToken?: string | null, guestSecret?: string | null, options?: FetchStreamTransportOptions): AsyncIterable<string>;
78
+ /**
79
+ * Wrap a transport so every yielded chunk ALSO pings `onChunk` — the
80
+ * byte-level liveness signal `useAgentInvoke`'s stall watchdog runs on
81
+ * (guuey#192). Purely observational: chunks pass through unchanged, errors
82
+ * and completion propagate untouched, and the wrapper adds no timers of its
83
+ * own — the OBSERVER owns the clock, this module only reports activity. The
84
+ * first ping doubles as the "first byte seen" arming signal, which is why
85
+ * the watchdog never fires during a silent cold start: no bytes, no ping,
86
+ * no armed timer (that phase belongs to {@link withColdStartRetry} and the
87
+ * user's own abort).
88
+ */
89
+ export declare function withActivityObserver(transport: InvokeTransport, onChunk: () => void): InvokeTransport;
90
+ //# sourceMappingURL=transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EAIL,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC5B,MAAM,uBAAuB,CAAC;AAE/B;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,kBAAkB,CAAC;AAgB5C;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAEpF;AAoHD,gDAAgD;AAChD,MAAM,WAAW,2BAA4B,SAAQ,sBAAsB;IACzE;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,qBAAqB,GAAG,KAAK,CAAC;IAC/C;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC1D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,aAAa,EAClB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAC3B,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAC3B,OAAO,GAAE,2BAAgC,GACxC,aAAa,CAAC,MAAM,CAAC,CAcvB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,eAAe,EAC1B,OAAO,EAAE,MAAM,IAAI,GAClB,eAAe,CAOjB"}