@guuey/agent-client 0.3.1 → 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.
- package/README.md +15 -2
- package/dist/error-codes.d.ts +58 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +55 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +36 -0
- package/dist/history.d.ts +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/index.d.ts +10 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -6
- package/dist/react.d.ts +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/saturation-retry.d.ts +60 -0
- package/dist/saturation-retry.d.ts.map +1 -0
- package/dist/saturation-retry.js +135 -0
- package/dist/sse.d.ts +1 -1
- package/dist/sse.d.ts.map +1 -1
- package/dist/types.d.ts +29 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/useAgentInvoke.d.ts +1 -1
- package/dist/useAgentInvoke.d.ts.map +1 -1
- package/dist/useAgentInvoke.js +23 -3
- package/dist/web-adapters.d.ts +38 -32
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +83 -22
- package/package.json +5 -5
- package/src/error-codes.ts +58 -0
- package/src/errors.ts +35 -0
- package/src/history.ts +1 -1
- package/src/index.ts +24 -8
- package/src/react.ts +1 -1
- package/src/saturation-retry.ts +150 -0
- package/src/sse.ts +1 -1
- package/src/types.ts +29 -3
- package/src/useAgentInvoke.ts +24 -4
- package/src/web-adapters.ts +121 -23
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single `POD_SATURATED` auto-retry, as a transport-agnostic wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Its own module — rather than living inside `./web-adapters.ts`, where it was
|
|
5
|
+
* born — because the behaviour is a property of the POD's refusal vocabulary,
|
|
6
|
+
* not of `fetch`. Every host that speaks `/agent/invoke` wants it, including
|
|
7
|
+
* the ones that cannot import the web adapter bundle: Portal's React-Native
|
|
8
|
+
* transport wraps its own `fetch` call with {@link withSaturationRetry} the
|
|
9
|
+
* same way `fetchStreamTransport` wraps its browser streaming reader, so the
|
|
10
|
+
* two wear byte-identical retry semantics instead of two hand-written copies
|
|
11
|
+
* that drift.
|
|
12
|
+
*
|
|
13
|
+
* This module imports only `./types.js`, `./errors.js` and `./error-codes.js`
|
|
14
|
+
* — all pure — so pulling it in costs a React-Native build nothing.
|
|
15
|
+
*/
|
|
16
|
+
import { AGENT_ERROR_CODES } from "./error-codes.js";
|
|
17
|
+
import { AgentResponseError } from "./errors.js";
|
|
18
|
+
import type { InvokeRequest, InvokeTransport } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fallback wait before the saturation retry when the pod sent no usable
|
|
22
|
+
* `Retry-After` — the same 15s the pod's governor hints today
|
|
23
|
+
* (`GOVERNOR_RETRY_AFTER_SECONDS`), so a stripped header behaves like the
|
|
24
|
+
* normal case rather than hammering.
|
|
25
|
+
*/
|
|
26
|
+
const SATURATION_FALLBACK_DELAY_SECONDS = 15;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Ceiling on the honoured hint. A pod that (mis)configures a multi-minute
|
|
30
|
+
* `Retry-After` must not park a chat UI in `connecting` for that long — past
|
|
31
|
+
* this the user is better served by the visible failure they can act on.
|
|
32
|
+
*/
|
|
33
|
+
const SATURATION_MAX_DELAY_SECONDS = 30;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read `Retry-After` as WHOLE SECONDS, or `undefined`.
|
|
37
|
+
*
|
|
38
|
+
* HTTP also allows an absolute HTTP-date, which is deliberately NOT parsed:
|
|
39
|
+
* the pod only ever emits a delta-seconds integer, and silently mis-reading a
|
|
40
|
+
* date as `NaN` seconds is worse than falling back to the fixed delay.
|
|
41
|
+
*
|
|
42
|
+
* Exported because every transport that builds an {@link AgentResponseError}
|
|
43
|
+
* has to fill `retryAfterSeconds` the same way for {@link withSaturationRetry}
|
|
44
|
+
* to honour the same hint — a second hand-rolled regex in a host adapter is
|
|
45
|
+
* exactly the drift this module exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
export function parseRetryAfterSeconds(header: string | null): number | undefined {
|
|
48
|
+
if (header === null) return undefined;
|
|
49
|
+
const trimmed = header.trim();
|
|
50
|
+
if (!/^\d+$/.test(trimmed)) return undefined;
|
|
51
|
+
const seconds = Number(trimmed);
|
|
52
|
+
return Number.isSafeInteger(seconds) ? seconds : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** How long to wait before the single saturation retry, in milliseconds. */
|
|
56
|
+
function saturationDelayMs(retryAfterSeconds: number | undefined): number {
|
|
57
|
+
const hinted = retryAfterSeconds ?? SATURATION_FALLBACK_DELAY_SECONDS;
|
|
58
|
+
return Math.min(hinted, SATURATION_MAX_DELAY_SECONDS) * 1000;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wait `ms`, or resolve early if the turn is aborted — a user who hits stop
|
|
63
|
+
* must not sit through the remainder of a 15s backoff before the UI settles.
|
|
64
|
+
*/
|
|
65
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
66
|
+
return new Promise<void>((resolve) => {
|
|
67
|
+
if (signal.aborted) {
|
|
68
|
+
resolve();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const finish = (): void => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
signal.removeEventListener("abort", finish);
|
|
74
|
+
resolve();
|
|
75
|
+
};
|
|
76
|
+
const timer = setTimeout(finish, ms);
|
|
77
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Options for {@link withSaturationRetry}. */
|
|
82
|
+
export interface SaturationRetryOptions {
|
|
83
|
+
/**
|
|
84
|
+
* The saturation-retry wait. Injectable so tests drive the retry without a
|
|
85
|
+
* real 15s timer; production uses an abort-aware `setTimeout`.
|
|
86
|
+
*/
|
|
87
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wrap an invoke transport with ONE automatic retry on a saturated pod.
|
|
92
|
+
*
|
|
93
|
+
* ## What retries, and what deliberately does not
|
|
94
|
+
*
|
|
95
|
+
* `POD_SATURATED` (503) means the pod is at its concurrent-turn cap right now
|
|
96
|
+
* — a transient queue state that clears as in-flight turns finish, so a single
|
|
97
|
+
* delayed re-send usually just works. The wait is the pod's own `Retry-After`
|
|
98
|
+
* hint (via {@link AgentResponseError.retryAfterSeconds}), defaulting to 15s
|
|
99
|
+
* when it sent none and capped at 30s.
|
|
100
|
+
*
|
|
101
|
+
* `DRAINING` (also 503 + `Retry-After`) is NOT retried in v1. The refusing pod
|
|
102
|
+
* is shutting down: its readiness probe is already failing and the endpoint
|
|
103
|
+
* pull is in flight, so the useful retry is the one that reaches a DIFFERENT
|
|
104
|
+
* pod — and a wrapped transport re-sends to the same URL. Retrying here would
|
|
105
|
+
* spend the user's 15s to arrive back at the same draining pod (or at a fresh
|
|
106
|
+
* one by luck), which is not a guarantee worth building on. When the retry can
|
|
107
|
+
* be made routing-aware, this is the code to revisit.
|
|
108
|
+
*
|
|
109
|
+
* Exactly ONE retry: a second saturation propagates as
|
|
110
|
+
* {@link AgentResponseError}, so a genuinely overloaded agent surfaces instead
|
|
111
|
+
* of looping. Nothing is retried once a chunk has been yielded — replaying
|
|
112
|
+
* mid-stream would duplicate a partial assistant turn (the same `yielded`
|
|
113
|
+
* guard the widget's `withIdentifiedToken` 401-retry uses). An abort during
|
|
114
|
+
* the wait skips the retry and surfaces the original refusal.
|
|
115
|
+
*
|
|
116
|
+
* The retry is INVISIBLE to `useAgentInvoke`: no frames were yielded, so the
|
|
117
|
+
* turn simply stays in `connecting` for the duration of the wait. There is no
|
|
118
|
+
* `retrying` status by design — the hook's state machine describes the pod's
|
|
119
|
+
* turn lifecycle, not the transport's plumbing.
|
|
120
|
+
*
|
|
121
|
+
* The wrapped transport is re-invoked from scratch for the retry, so a host
|
|
122
|
+
* that resolves identity inside its own generator (Portal's RN transport reads
|
|
123
|
+
* the bearer bridge per attempt) re-reads it on the second try rather than
|
|
124
|
+
* replaying a token that may have expired during the wait.
|
|
125
|
+
*/
|
|
126
|
+
export function withSaturationRetry(
|
|
127
|
+
transport: InvokeTransport,
|
|
128
|
+
options: SaturationRetryOptions = {},
|
|
129
|
+
): InvokeTransport {
|
|
130
|
+
return async function* retrying(req: InvokeRequest): AsyncGenerator<string> {
|
|
131
|
+
let yielded = false;
|
|
132
|
+
try {
|
|
133
|
+
for await (const chunk of transport(req)) {
|
|
134
|
+
yielded = true;
|
|
135
|
+
yield chunk;
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const saturated =
|
|
140
|
+
err instanceof AgentResponseError && err.code === AGENT_ERROR_CODES.POD_SATURATED;
|
|
141
|
+
if (!saturated || yielded) throw err;
|
|
142
|
+
await (options.sleep ?? delay)(saturationDelayMs(err.retryAfterSeconds), req.signal);
|
|
143
|
+
// Aborted mid-wait: the user is done with this turn. Surface the refusal
|
|
144
|
+
// that caused the wait rather than spending a request that `fetch` would
|
|
145
|
+
// reject on the signal anyway.
|
|
146
|
+
if (req.signal.aborted) throw err;
|
|
147
|
+
}
|
|
148
|
+
yield* transport(req);
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/sse.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* verbatim across web (Studio) and React-Native (Portal).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ProfileConsentRequest, ProfileLinkRequest } from "./types";
|
|
7
|
+
import type { ProfileConsentRequest, ProfileLinkRequest } from "./types.js";
|
|
8
8
|
|
|
9
9
|
export interface ParsedSseEvent {
|
|
10
10
|
event: string;
|
package/src/types.ts
CHANGED
|
@@ -159,9 +159,16 @@ export interface UseAgentInvokeOptions {
|
|
|
159
159
|
* - `responding` — assistant text is arriving (`text.start`/`text.delta`
|
|
160
160
|
* silver frames, or bypass text/assistant frames).
|
|
161
161
|
*
|
|
162
|
-
* Failure keeps its own channel ({@link UseAgentInvokeReturn.error}
|
|
163
|
-
* is deliberately no `error`
|
|
164
|
-
* returns to `ready` so the
|
|
162
|
+
* Failure keeps its own channel ({@link UseAgentInvokeReturn.error} +
|
|
163
|
+
* {@link UseAgentInvokeReturn.errorCode}) — there is deliberately no `error`
|
|
164
|
+
* status: after any terminal outcome the status returns to `ready` so the
|
|
165
|
+
* composer re-enables.
|
|
166
|
+
*
|
|
167
|
+
* There is likewise no `retrying` state. `fetchStreamTransport` retries a
|
|
168
|
+
* `POD_SATURATED` refusal once by itself, but that happens before any frame is
|
|
169
|
+
* yielded, so the turn stays in `connecting` for the backoff and the hook never
|
|
170
|
+
* learns it happened. This union describes the POD's turn lifecycle; transport
|
|
171
|
+
* plumbing does not belong in it.
|
|
165
172
|
*/
|
|
166
173
|
export type AgentInvokeStatus = "ready" | "connecting" | "thinking" | "using-tool" | "responding";
|
|
167
174
|
|
|
@@ -174,6 +181,25 @@ export interface UseAgentInvokeReturn {
|
|
|
174
181
|
/** The active tool's wire name while `status === 'using-tool'`, else null. */
|
|
175
182
|
activeTool: string | null;
|
|
176
183
|
error: string | null;
|
|
184
|
+
/**
|
|
185
|
+
* The pod's wire code for the failure in {@link error}, when it carried one
|
|
186
|
+
* — `QUOTA_EXCEEDED`, `POD_SATURATED`, `GUEST_ACCESS_DISABLED`, … (see
|
|
187
|
+
* `AGENT_ERROR_CODES`, and branch on those constants rather than re-typing
|
|
188
|
+
* the literals). `null` when there is no error, or when the failure had no
|
|
189
|
+
* code: a network drop, a host-adapter throw, or an `event: error` frame
|
|
190
|
+
* without one.
|
|
191
|
+
*
|
|
192
|
+
* Both failure channels feed it — the pre-stream refusal (thrown as
|
|
193
|
+
* `AgentResponseError`) and the in-band `event: error` frame — because the
|
|
194
|
+
* two carry the SAME vocabulary; a consumer branches once, not per channel.
|
|
195
|
+
* Set and cleared in lockstep with {@link error}: a new `send()`, `reset()`,
|
|
196
|
+
* or an app switch clears both.
|
|
197
|
+
*
|
|
198
|
+
* It is a `string`, not the `AgentErrorCode` union: the pod may ship a new
|
|
199
|
+
* code before a consumer upgrades this SDK, and a narrowed type would make
|
|
200
|
+
* that unrepresentable rather than merely unhandled.
|
|
201
|
+
*/
|
|
202
|
+
errorCode: string | null;
|
|
177
203
|
threadId: string | null;
|
|
178
204
|
/** Abort the in-flight turn (the stream stops; partial text is kept). */
|
|
179
205
|
abort: () => void;
|
package/src/useAgentInvoke.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* useAgentInvoke — the base-platform chat client.
|
|
3
3
|
*
|
|
4
4
|
* Speaks the nocode-runtime pod's Bedrock-style SSE contract (NOT the parked
|
|
5
|
-
* ggui generative-UI protocol that `@ggui-ai/react`'s useInvoke targets):
|
|
5
|
+
* ggui generative-UI protocol that `@ggui-ai/mcp-apps-react`'s useInvoke targets):
|
|
6
6
|
*
|
|
7
7
|
* POST {endpointUrl}/agent/invoke
|
|
8
8
|
* body: { input, threadId?, clientMessageId }
|
|
@@ -29,8 +29,9 @@ import {
|
|
|
29
29
|
parseSseEvents,
|
|
30
30
|
reduceAssistantText,
|
|
31
31
|
stringField,
|
|
32
|
-
} from "./sse";
|
|
33
|
-
import { ingestMessageFrame } from "./blocks";
|
|
32
|
+
} from "./sse.js";
|
|
33
|
+
import { ingestMessageFrame } from "./blocks.js";
|
|
34
|
+
import { AgentResponseError } from "./errors.js";
|
|
34
35
|
import type {
|
|
35
36
|
AgentInvokeAdapters,
|
|
36
37
|
AgentInvokeStatus,
|
|
@@ -41,7 +42,7 @@ import type {
|
|
|
41
42
|
ProfileLinkRequest,
|
|
42
43
|
UseAgentInvokeOptions,
|
|
43
44
|
UseAgentInvokeReturn,
|
|
44
|
-
} from "./types";
|
|
45
|
+
} from "./types.js";
|
|
45
46
|
|
|
46
47
|
function threadStorageKey(appId: string | undefined): string {
|
|
47
48
|
return `guuey:thread:${appId ?? "default"}`;
|
|
@@ -78,6 +79,10 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
78
79
|
const [status, setStatus] = useState<AgentInvokeStatus>("ready");
|
|
79
80
|
const [activeTool, setActiveTool] = useState<string | null>(null);
|
|
80
81
|
const [error, setError] = useState<string | null>(null);
|
|
82
|
+
// The pod's wire code for whatever put `error` there, when the failure
|
|
83
|
+
// carried one (see the return-type contract). Moves in lockstep with
|
|
84
|
+
// `error` — every set/clear of one touches the other.
|
|
85
|
+
const [errorCode, setErrorCode] = useState<string | null>(null);
|
|
81
86
|
const [threadId, setThreadId] = useState<string | null>(null);
|
|
82
87
|
// Opt-in block-preserving transcript. `reduceResult` follows the
|
|
83
88
|
// null-until-first-valid-AgEvent contract documented on the return type: it
|
|
@@ -131,6 +136,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
131
136
|
setThreadId(null);
|
|
132
137
|
setMessages([]);
|
|
133
138
|
setError(null);
|
|
139
|
+
setErrorCode(null);
|
|
134
140
|
setStatus("ready");
|
|
135
141
|
setActiveTool(null);
|
|
136
142
|
// Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
|
|
@@ -221,6 +227,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
221
227
|
void adaptersRef.current.storage.save(threadStorageKey(appId), "");
|
|
222
228
|
setMessages([]);
|
|
223
229
|
setError(null);
|
|
230
|
+
setErrorCode(null);
|
|
224
231
|
setStatus("ready");
|
|
225
232
|
setActiveTool(null);
|
|
226
233
|
// Re-create the reducer for the new conversation (rebuilt lazily on the
|
|
@@ -244,6 +251,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
244
251
|
async (input: string) => {
|
|
245
252
|
if (!endpointUrl || !input.trim() || status !== "ready") return;
|
|
246
253
|
setError(null);
|
|
254
|
+
setErrorCode(null);
|
|
247
255
|
setStatus("connecting");
|
|
248
256
|
setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
|
|
249
257
|
|
|
@@ -336,7 +344,12 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
336
344
|
}
|
|
337
345
|
}
|
|
338
346
|
} else if (ev.event === "error") {
|
|
347
|
+
// In-band failure frame — one of the two channels that carry the
|
|
348
|
+
// pod's wire code (the other is the pre-stream refusal caught
|
|
349
|
+
// below). A frame without a `code` clears it rather than leaving
|
|
350
|
+
// a previous turn's code standing beside a new message.
|
|
339
351
|
setError(stringField(ev.data, "message") ?? "agent error");
|
|
352
|
+
setErrorCode(stringField(ev.data, "code") ?? null);
|
|
340
353
|
} else if (ev.event === "profile-consent-needed") {
|
|
341
354
|
// Cross-app profile consent ask (T6). Only a well-formed payload
|
|
342
355
|
// updates state; a malformed one is dropped, leaving any prior
|
|
@@ -357,6 +370,12 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
357
370
|
} catch (e) {
|
|
358
371
|
if (!controller.signal.aborted) {
|
|
359
372
|
setError(e instanceof Error ? e.message : "failed to reach agent");
|
|
373
|
+
// Pre-stream refusals arrive as a thrown AgentResponseError carrying
|
|
374
|
+
// the pod's structured code (a transport-level saturation retry has
|
|
375
|
+
// already happened and failed by the time one surfaces here). Any
|
|
376
|
+
// other throw — a network drop, a host-adapter failure — has no wire
|
|
377
|
+
// code, so the field stays null beside the message.
|
|
378
|
+
setErrorCode(e instanceof AgentResponseError ? (e.code ?? null) : null);
|
|
360
379
|
}
|
|
361
380
|
} finally {
|
|
362
381
|
setStatus("ready");
|
|
@@ -384,6 +403,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
384
403
|
status,
|
|
385
404
|
activeTool,
|
|
386
405
|
error,
|
|
406
|
+
errorCode,
|
|
387
407
|
threadId,
|
|
388
408
|
abort,
|
|
389
409
|
reset,
|
package/src/web-adapters.ts
CHANGED
|
@@ -7,35 +7,27 @@
|
|
|
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
18
|
import type {
|
|
15
19
|
AgentInvokeAdapters,
|
|
16
20
|
InvokeRequest,
|
|
17
21
|
InvokeTransport,
|
|
18
22
|
ThreadIdStore,
|
|
19
|
-
} from "./types";
|
|
20
|
-
import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
}
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
|
|
25
|
+
import { AgentResponseError } from "./errors.js";
|
|
26
|
+
import {
|
|
27
|
+
parseRetryAfterSeconds,
|
|
28
|
+
withSaturationRetry,
|
|
29
|
+
type SaturationRetryOptions,
|
|
30
|
+
} from "./saturation-retry.js";
|
|
39
31
|
|
|
40
32
|
/** Persists the threadId in `window.localStorage` (synchronously). */
|
|
41
33
|
export const localStorageThreadStore: ThreadIdStore = {
|
|
@@ -104,7 +96,11 @@ function sendableGuestSecret(secret: string | null | undefined): string | null {
|
|
|
104
96
|
}
|
|
105
97
|
|
|
106
98
|
/**
|
|
107
|
-
*
|
|
99
|
+
* One invoke attempt: opens the request and yields decoded SSE chunks.
|
|
100
|
+
* {@link fetchStreamTransport} wraps this with the shared saturation retry —
|
|
101
|
+
* every behaviour below is per-attempt.
|
|
102
|
+
*
|
|
103
|
+
* Exactly ONE identity carrier per request, in order:
|
|
108
104
|
*
|
|
109
105
|
* 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
|
|
110
106
|
* by their verified access token (the same identity the history read
|
|
@@ -122,7 +118,7 @@ function sendableGuestSecret(secret: string | null | undefined): string | null {
|
|
|
122
118
|
*
|
|
123
119
|
* Reads the body via `ReadableStream.getReader()` (browser).
|
|
124
120
|
*/
|
|
125
|
-
|
|
121
|
+
async function* streamInvokeOnce(
|
|
126
122
|
req: InvokeRequest,
|
|
127
123
|
accessToken?: string | null,
|
|
128
124
|
guestSecret?: string | null,
|
|
@@ -161,7 +157,12 @@ export async function* fetchStreamTransport(
|
|
|
161
157
|
code = body.code;
|
|
162
158
|
}
|
|
163
159
|
}
|
|
164
|
-
throw new AgentResponseError(
|
|
160
|
+
throw new AgentResponseError(
|
|
161
|
+
message,
|
|
162
|
+
resp.status,
|
|
163
|
+
code,
|
|
164
|
+
parseRetryAfterSeconds(resp.headers.get("Retry-After")),
|
|
165
|
+
);
|
|
165
166
|
}
|
|
166
167
|
const reader = resp.body.getReader();
|
|
167
168
|
const decoder = new TextDecoder();
|
|
@@ -172,6 +173,26 @@ export async function* fetchStreamTransport(
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The web SSE transport: {@link streamInvokeOnce} under the shared
|
|
178
|
+
* {@link withSaturationRetry} wrapper. Every consumer of this transport
|
|
179
|
+
* (Studio, the widget, anything built on {@link createWebAdapters}) therefore
|
|
180
|
+
* inherits the single `POD_SATURATED` retry, and inherits the SAME one Portal's
|
|
181
|
+
* React-Native transport wears — see that wrapper's docblock for which refusals
|
|
182
|
+
* retry, which deliberately do not, and why the retry is invisible to the hook.
|
|
183
|
+
*/
|
|
184
|
+
export function fetchStreamTransport(
|
|
185
|
+
req: InvokeRequest,
|
|
186
|
+
accessToken?: string | null,
|
|
187
|
+
guestSecret?: string | null,
|
|
188
|
+
options: SaturationRetryOptions = {},
|
|
189
|
+
): AsyncIterable<string> {
|
|
190
|
+
return withSaturationRetry(
|
|
191
|
+
(attempt) => streamInvokeOnce(attempt, accessToken, guestSecret),
|
|
192
|
+
options,
|
|
193
|
+
)(req);
|
|
194
|
+
}
|
|
195
|
+
|
|
175
196
|
export interface CreateWebAdaptersOptions {
|
|
176
197
|
/**
|
|
177
198
|
* Public read-plane base (ending in `/v1`) for transcript history. When
|
|
@@ -406,3 +427,80 @@ export function createUiResourceReader(
|
|
|
406
427
|
};
|
|
407
428
|
return createMcpUiResourceReader({ readResource });
|
|
408
429
|
}
|
|
430
|
+
|
|
431
|
+
/** Options for {@link createUiActionRelay} — same credential surface as the reader. */
|
|
432
|
+
export interface CreateUiActionRelayOptions {
|
|
433
|
+
/** The guuey public API base (`…/v1`). */
|
|
434
|
+
apiBaseUrl: string;
|
|
435
|
+
/** The thread whose persisted cards this relay may act for. */
|
|
436
|
+
threadId: string;
|
|
437
|
+
/** Signed-in bearer — wins over the guest secret (same rule as the transport). */
|
|
438
|
+
getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
439
|
+
/** Caller-owned anonymous guest secret (widget / guest chat). */
|
|
440
|
+
guestSecret?: string | null;
|
|
441
|
+
/** Injectable for tests. */
|
|
442
|
+
fetchImpl?: typeof fetch;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Build the card action relay over guuey's authenticated `tools/call` proxy
|
|
447
|
+
* (guuey#158: `POST /v1/threads/:threadId/ui-action`) — the mirror of
|
|
448
|
+
* {@link createUiResourceReader}. Allowlisting, arm narrowing, and the
|
|
449
|
+
* never-reject contract live in `@guuey/mcp-apps-host`'s
|
|
450
|
+
* `createMcpUiActionRelay`; only the transport is guuey-shaped. The proxy
|
|
451
|
+
* owns EVERYTHING trust-shaped (identity, thread ownership, the
|
|
452
|
+
* locator-to-thread guard, its own server-side allowlist, the per-user
|
|
453
|
+
* federation mint) — and every non-OK here collapses to `undefined`, which
|
|
454
|
+
* the host relay answers in-band as an `isError` result, never a thrown
|
|
455
|
+
* error into the sandbox bridge.
|
|
456
|
+
*/
|
|
457
|
+
export function createUiActionRelay(
|
|
458
|
+
options: CreateUiActionRelayOptions,
|
|
459
|
+
): (request: UiActionRequest) => Promise<McpToolCallResult> {
|
|
460
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
461
|
+
const callTool = async (
|
|
462
|
+
uri: string,
|
|
463
|
+
name: string,
|
|
464
|
+
args: McpToolStructuredContent | undefined,
|
|
465
|
+
): Promise<unknown> => {
|
|
466
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
467
|
+
const token = options.getAccessToken ? await options.getAccessToken() : null;
|
|
468
|
+
const guest = sendableGuestSecret(options.guestSecret);
|
|
469
|
+
if (token) {
|
|
470
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
471
|
+
} else if (guest) {
|
|
472
|
+
headers[GUEST_HEADER] = guest;
|
|
473
|
+
}
|
|
474
|
+
const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
|
|
475
|
+
const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
|
|
476
|
+
let res: Response;
|
|
477
|
+
try {
|
|
478
|
+
res = await fetchImpl(requestUrl, { method: "POST", headers, body });
|
|
479
|
+
} catch {
|
|
480
|
+
return undefined; // transport failure — the host relay answers in-band
|
|
481
|
+
}
|
|
482
|
+
// One forceRefresh retry on 401 with a bearer in play — the same
|
|
483
|
+
// expired-but-refreshable recovery the reader performs.
|
|
484
|
+
if (res.status === 401 && options.getAccessToken) {
|
|
485
|
+
const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
|
|
486
|
+
if (fresh) {
|
|
487
|
+
try {
|
|
488
|
+
res = await fetchImpl(requestUrl, {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers: { ...headers, authorization: `Bearer ${fresh}` },
|
|
491
|
+
body,
|
|
492
|
+
});
|
|
493
|
+
} catch {
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (!res.ok) return undefined;
|
|
499
|
+
try {
|
|
500
|
+
return (await res.json()) as unknown;
|
|
501
|
+
} catch {
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
return createMcpUiActionRelay({ callTool });
|
|
506
|
+
}
|