@guuey/agent-client 0.3.0 → 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 +43 -54
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +114 -27
- package/package.json +6 -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 -9
- 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 +144 -54
|
@@ -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
|
@@ -6,31 +6,28 @@
|
|
|
6
6
|
* functions — never at module load — so this file is import-safe under SSR
|
|
7
7
|
* (the functions guard on `typeof window`).
|
|
8
8
|
*/
|
|
9
|
+
import {
|
|
10
|
+
createMcpUiActionRelay,
|
|
11
|
+
createMcpUiResourceReader,
|
|
12
|
+
type McpResourceReadResult,
|
|
13
|
+
type McpToolCallResult,
|
|
14
|
+
type McpToolStructuredContent,
|
|
15
|
+
type ResolvedViewMount,
|
|
16
|
+
type UiActionRequest,
|
|
17
|
+
} from "@guuey/mcp-apps-host";
|
|
9
18
|
import type {
|
|
10
19
|
AgentInvokeAdapters,
|
|
11
20
|
InvokeRequest,
|
|
12
21
|
InvokeTransport,
|
|
13
22
|
ThreadIdStore,
|
|
14
|
-
} from "./types";
|
|
15
|
-
import { fetchThreadHistory, HistoryUnauthorizedError } from "./history";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* status for non-JSON failures.
|
|
23
|
-
*/
|
|
24
|
-
export class AgentResponseError extends Error {
|
|
25
|
-
constructor(
|
|
26
|
-
message: string,
|
|
27
|
-
readonly status: number,
|
|
28
|
-
readonly code?: string,
|
|
29
|
-
) {
|
|
30
|
-
super(message);
|
|
31
|
-
this.name = "AgentResponseError";
|
|
32
|
-
}
|
|
33
|
-
}
|
|
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";
|
|
34
31
|
|
|
35
32
|
/** Persists the threadId in `window.localStorage` (synchronously). */
|
|
36
33
|
export const localStorageThreadStore: ThreadIdStore = {
|
|
@@ -99,7 +96,11 @@ function sendableGuestSecret(secret: string | null | undefined): string | null {
|
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
/**
|
|
102
|
-
*
|
|
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:
|
|
103
104
|
*
|
|
104
105
|
* 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
|
|
105
106
|
* by their verified access token (the same identity the history read
|
|
@@ -117,7 +118,7 @@ function sendableGuestSecret(secret: string | null | undefined): string | null {
|
|
|
117
118
|
*
|
|
118
119
|
* Reads the body via `ReadableStream.getReader()` (browser).
|
|
119
120
|
*/
|
|
120
|
-
|
|
121
|
+
async function* streamInvokeOnce(
|
|
121
122
|
req: InvokeRequest,
|
|
122
123
|
accessToken?: string | null,
|
|
123
124
|
guestSecret?: string | null,
|
|
@@ -156,7 +157,12 @@ export async function* fetchStreamTransport(
|
|
|
156
157
|
code = body.code;
|
|
157
158
|
}
|
|
158
159
|
}
|
|
159
|
-
throw new AgentResponseError(
|
|
160
|
+
throw new AgentResponseError(
|
|
161
|
+
message,
|
|
162
|
+
resp.status,
|
|
163
|
+
code,
|
|
164
|
+
parseRetryAfterSeconds(resp.headers.get("Retry-After")),
|
|
165
|
+
);
|
|
160
166
|
}
|
|
161
167
|
const reader = resp.body.getReader();
|
|
162
168
|
const decoder = new TextDecoder();
|
|
@@ -167,6 +173,26 @@ export async function* fetchStreamTransport(
|
|
|
167
173
|
}
|
|
168
174
|
}
|
|
169
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
|
+
|
|
170
196
|
export interface CreateWebAdaptersOptions {
|
|
171
197
|
/**
|
|
172
198
|
* Public read-plane base (ending in `/v1`) for transcript history. When
|
|
@@ -333,39 +359,24 @@ export interface CreateUiResourceReaderOptions {
|
|
|
333
359
|
}
|
|
334
360
|
|
|
335
361
|
/**
|
|
336
|
-
* Build a
|
|
337
|
-
*
|
|
338
|
-
* `GET /v1/threads/:threadId/ui-resource?uri=…`).
|
|
362
|
+
* Build a `UiResourceReader` over guuey's authenticated resources/read proxy
|
|
363
|
+
* (guuey#122 Gap 1: `GET /v1/threads/:threadId/ui-resource?uri=…`).
|
|
339
364
|
*
|
|
365
|
+
* This is `@guuey/mcp-apps-host`'s `createMcpUiResourceReader` assembly over
|
|
366
|
+
* a guuey-platform transport (guuey#127) — channel resolution and payload
|
|
367
|
+
* narrowing live in the host package; only the transport is guuey-shaped.
|
|
340
368
|
* The proxy owns EVERYTHING trust-shaped: caller identity (the same three
|
|
341
369
|
* families as the history read), thread ownership, the locator-to-thread
|
|
342
|
-
* scope guard, and the per-user federation mint. This
|
|
370
|
+
* scope guard, and the per-user federation mint. This transport only carries
|
|
343
371
|
* the surface's existing credential and maps EVERY non-OK — 401/403/404/502
|
|
344
372
|
* alike — to `undefined`: deny is byte-identical to a miss, and a miss
|
|
345
373
|
* renders the host's placeholder, never an error surface.
|
|
346
|
-
*
|
|
347
|
-
* Channel resolution is the phase-1 heuristic: a `ui://ggui/…` uri mounts in
|
|
348
|
-
* the ggui-CSP sandbox page (`channel: "ggui"`), anything else in the
|
|
349
|
-
* self-only page (`channel: "inline"`). Phase 2 (per-resource declared-CSP
|
|
350
|
-
* construction from the response `_meta.ui.csp`) retires the heuristic —
|
|
351
|
-
* conformance map, retirement step 3.
|
|
352
|
-
*/
|
|
353
|
-
/**
|
|
354
|
-
* The mount the reader resolves — structurally assignable to
|
|
355
|
-
* `@guuey/mcp-apps-host`'s `ViewMount` mountable arms (typed inline so this
|
|
356
|
-
* package carries no dependency on the host package; hosts that consume
|
|
357
|
-
* both see one structural type).
|
|
358
374
|
*/
|
|
359
|
-
export interface ResolvedUiResourceMount {
|
|
360
|
-
channel: "inline" | "ggui";
|
|
361
|
-
resource: { uri: string; mimeType?: string; text: string };
|
|
362
|
-
}
|
|
363
|
-
|
|
364
375
|
export function createUiResourceReader(
|
|
365
376
|
options: CreateUiResourceReaderOptions,
|
|
366
|
-
): (resourceUri: string) => Promise<
|
|
377
|
+
): (resourceUri: string) => Promise<ResolvedViewMount | undefined> {
|
|
367
378
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
368
|
-
|
|
379
|
+
const readResource = async (resourceUri: string): Promise<McpResourceReadResult | undefined> => {
|
|
369
380
|
const headers: Record<string, string> = {};
|
|
370
381
|
const token = options.getAccessToken ? await options.getAccessToken() : null;
|
|
371
382
|
const guest = sendableGuestSecret(options.guestSecret);
|
|
@@ -397,20 +408,99 @@ export function createUiResourceReader(
|
|
|
397
408
|
}
|
|
398
409
|
}
|
|
399
410
|
if (!res.ok) return undefined;
|
|
400
|
-
let body: { uri?: unknown; mimeType?: unknown; text?: unknown };
|
|
411
|
+
let body: { uri?: unknown; mimeType?: unknown; text?: unknown; blob?: unknown };
|
|
401
412
|
try {
|
|
402
413
|
body = (await res.json()) as typeof body;
|
|
403
414
|
} catch {
|
|
404
415
|
return undefined;
|
|
405
416
|
}
|
|
406
|
-
|
|
417
|
+
// The proxy passes the blob arm through (a blob-only resource is not
|
|
418
|
+
// silently a miss — its route contract); mirror that here.
|
|
419
|
+
if (typeof body.uri !== "string") return undefined;
|
|
420
|
+
if (typeof body.text !== "string" && typeof body.blob !== "string") return undefined;
|
|
407
421
|
return {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
text: body.text,
|
|
413
|
-
},
|
|
422
|
+
uri: body.uri,
|
|
423
|
+
...(typeof body.mimeType === "string" ? { mimeType: body.mimeType } : {}),
|
|
424
|
+
...(typeof body.text === "string" ? { text: body.text } : {}),
|
|
425
|
+
...(typeof body.blob === "string" ? { blob: body.blob } : {}),
|
|
414
426
|
};
|
|
415
427
|
};
|
|
428
|
+
return createMcpUiResourceReader({ readResource });
|
|
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 });
|
|
416
506
|
}
|