@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.
@@ -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}) — there
163
- * is deliberately no `error` status: after any terminal outcome the status
164
- * returns to `ready` so the composer re-enables.
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;
@@ -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,
@@ -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
- * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
24
- * SSE stream opens). Carries the pod's structured `{ code, message }` when
25
- * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
26
- * generation limit…") the chat UI should surface — falling back to the bare
27
- * status for non-JSON failures.
28
- */
29
- export class AgentResponseError extends Error {
30
- constructor(
31
- message: string,
32
- readonly status: number,
33
- readonly code?: string,
34
- ) {
35
- super(message);
36
- this.name = "AgentResponseError";
37
- }
38
- }
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
- * Web SSE transport. Exactly ONE identity carrier per request, in order:
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
- export async function* fetchStreamTransport(
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(message, resp.status, code);
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
+ }