@guuey/agent-client 0.7.1 → 0.8.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/dist/error-codes.d.ts +6 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +6 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/invoke-turn.d.ts +1 -4
- package/dist/invoke-turn.d.ts.map +1 -1
- package/dist/invoke-turn.js +5 -10
- package/dist/react.d.ts +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/sse.d.ts +6 -15
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +5 -24
- package/dist/types.d.ts +22 -31
- package/dist/types.d.ts.map +1 -1
- package/dist/useAgentInvoke.d.ts +8 -0
- package/dist/useAgentInvoke.d.ts.map +1 -1
- package/dist/useAgentInvoke.js +20 -18
- package/dist/web-adapters.d.ts +73 -0
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +153 -6
- package/package.json +2 -2
- package/src/error-codes.ts +6 -0
- package/src/index.ts +14 -3
- package/src/invoke-turn.ts +6 -22
- package/src/react.ts +1 -0
- package/src/sse.ts +6 -23
- package/src/types.ts +22 -32
- package/src/useAgentInvoke.ts +23 -20
- package/src/web-adapters.ts +208 -10
package/src/web-adapters.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ResolvedViewMount,
|
|
16
16
|
type UiActionRequest,
|
|
17
17
|
} from "@guuey/mcp-apps-host";
|
|
18
|
+
import type { AgHitlAnswer } from "@silverprotocol/core";
|
|
18
19
|
import type { AgentInvokeAdapters, InvokeTransport, ThreadIdStore } from "./types.js";
|
|
19
20
|
import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
|
|
20
21
|
import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
|
|
@@ -252,6 +253,18 @@ function toUiResourceUrl(endpointUrl: string): string {
|
|
|
252
253
|
return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
/** `<pod base>/agent/ui-action` — the live ACTION door (guuey#222), the read door's twin. */
|
|
257
|
+
function toUiActionUrl(endpointUrl: string): string {
|
|
258
|
+
return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-action");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Warn once per module load — sibling of the reader's flag; per-surface, not per-click. */
|
|
262
|
+
let relayEndpointWarned = false;
|
|
263
|
+
/** @internal test seam — the once-flag is module state; suites reset it between cases. */
|
|
264
|
+
export function __resetRelayEndpointWarning(): void {
|
|
265
|
+
relayEndpointWarned = false;
|
|
266
|
+
}
|
|
267
|
+
|
|
255
268
|
/**
|
|
256
269
|
* Build a `UiResourceReader` over guuey's authenticated resources/read
|
|
257
270
|
* doors — the pod door for LIVE turns (guuey#209 C1:
|
|
@@ -370,6 +383,24 @@ export interface CreateUiActionRelayOptions {
|
|
|
370
383
|
apiBaseUrl: string;
|
|
371
384
|
/** The thread whose persisted cards this relay may act for. */
|
|
372
385
|
threadId: string;
|
|
386
|
+
/**
|
|
387
|
+
* The surface's invoke endpoint (pod base URL or full `/agent/invoke`
|
|
388
|
+
* URL). When set, actions POST to the POD's live door first
|
|
389
|
+
* (`POST <pod>/agent/ui-action`, guuey#222) — the only authority that
|
|
390
|
+
* can relay a click for a card whose turn is still streaming (persisted
|
|
391
|
+
* `kind:'card'` rows land at turn COMPLETION, so the platform door 404s
|
|
392
|
+
* mid-turn by construction). A pod 404 (not live, or past the ledger's
|
|
393
|
+
* grace window) falls through to the platform door; every other pod
|
|
394
|
+
* answer is terminal for the same reason it would be on the platform
|
|
395
|
+
* door. Absent → platform door only (pre-#222 behavior): **a click on a
|
|
396
|
+
* card produced mid-turn cannot reach the agent until its turn
|
|
397
|
+
* completes** — the exact "no moment where a click both resolves AND
|
|
398
|
+
* finds a live consumer" defect. A live surface MUST pass it; omitting it
|
|
399
|
+
* is only correct for a pure history viewer with no pod. The relay warns
|
|
400
|
+
* once at construction when a platform door is configured without a pod
|
|
401
|
+
* door (same guardrail as {@link createUiResourceReader}).
|
|
402
|
+
*/
|
|
403
|
+
endpointUrl?: string | null;
|
|
373
404
|
/** Signed-in bearer — wins over the guest secret (same rule as the transport). */
|
|
374
405
|
getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
375
406
|
/** Caller-owned anonymous guest secret (widget / guest chat). */
|
|
@@ -394,29 +425,61 @@ export function createUiActionRelay(
|
|
|
394
425
|
options: CreateUiActionRelayOptions,
|
|
395
426
|
): (request: UiActionRequest) => Promise<McpToolCallResult> {
|
|
396
427
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
428
|
+
// A platform door without a pod door is almost always a live surface
|
|
429
|
+
// that forgot `endpointUrl` — its cards' clicks would go nowhere for the
|
|
430
|
+
// whole mid-turn window (guuey#222). `null` is the explicit "history-only
|
|
431
|
+
// viewer, there is no pod" opt-out; `undefined` is the forgotten case.
|
|
432
|
+
if (options.endpointUrl === undefined && !relayEndpointWarned) {
|
|
433
|
+
relayEndpointWarned = true;
|
|
434
|
+
console.warn(
|
|
435
|
+
"createUiActionRelay: no `endpointUrl` — a click on a card produced mid-turn cannot reach the agent until the turn completes (the pod door is the only authority while a turn streams; post-turn clicks reach the platform door). Pass the surface's invoke endpoint, or `endpointUrl: null` to declare a history-only viewer.",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* One door: POST + the reader's 401-forceRefresh recovery. Returns the
|
|
441
|
+
* parsed result on 2xx, `"miss"` on 404 (the pod's "not live / not yours /
|
|
442
|
+
* past grace" — deny==miss, so the NEXT door may still answer), and
|
|
443
|
+
* `undefined` for every other failure (terminal: the host relay answers
|
|
444
|
+
* in-band as an `isError` result, never a thrown error into the sandbox
|
|
445
|
+
* bridge). A pod 502 UPSTREAM_UNAVAILABLE is a real failure, not a miss —
|
|
446
|
+
* the persisted door cannot relay a mid-turn click either, so falling
|
|
447
|
+
* through would only trade one honest error for a misleading 404.
|
|
448
|
+
*/
|
|
449
|
+
const postDoor = async (
|
|
450
|
+
requestUrl: string,
|
|
451
|
+
body: string,
|
|
452
|
+
): Promise<{ kind: "result"; value: unknown } | "miss" | undefined> => {
|
|
453
|
+
// Exactly ONE identity carrier per call — the reader's rule verbatim:
|
|
454
|
+
// bearer → guest header → else cookie credentials (the HttpOnly
|
|
455
|
+
// `guuey_guest` cookie the pod mints for anonymous browser callers).
|
|
456
|
+
// Without the third arm a cookie-mode guest POSTed identity-less and
|
|
457
|
+
// every click failed auth (the guuey#221 class, on the relay). A JSON
|
|
458
|
+
// POST is always preflighted, so unlike the reader's GET this arm can
|
|
459
|
+
// never be a CORS "simple request" — which is fine because both doors
|
|
460
|
+
// answer a credentialed preflight: the pod echoes origin +
|
|
461
|
+
// `Access-Control-Allow-Credentials` on OPTIONS and every status, and
|
|
462
|
+
// the platform door's own OPTIONS branch does the same (guuey#224).
|
|
402
463
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
464
|
+
const init: RequestInit = { method: "POST", headers, body };
|
|
403
465
|
const token = options.getAccessToken ? await options.getAccessToken() : null;
|
|
404
466
|
const guest = sendableGuestSecret(options.guestSecret);
|
|
405
467
|
if (token) {
|
|
406
468
|
headers["authorization"] = `Bearer ${token}`;
|
|
407
469
|
} else if (guest) {
|
|
408
470
|
headers[GUEST_HEADER] = guest;
|
|
471
|
+
} else {
|
|
472
|
+
init.credentials = "include";
|
|
409
473
|
}
|
|
410
|
-
const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
|
|
411
|
-
const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
|
|
412
474
|
let res: Response;
|
|
413
475
|
try {
|
|
414
|
-
res = await fetchImpl(requestUrl,
|
|
476
|
+
res = await fetchImpl(requestUrl, init);
|
|
415
477
|
} catch {
|
|
416
478
|
return undefined; // transport failure — the host relay answers in-band
|
|
417
479
|
}
|
|
418
480
|
// One forceRefresh retry on 401 with a bearer in play — the same
|
|
419
|
-
// expired-but-refreshable recovery the reader performs.
|
|
481
|
+
// expired-but-refreshable recovery the reader performs. The retry
|
|
482
|
+
// carries the fresh bearer and nothing else (same one-carrier rule).
|
|
420
483
|
if (res.status === 401 && options.getAccessToken) {
|
|
421
484
|
const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
|
|
422
485
|
if (fresh) {
|
|
@@ -431,12 +494,147 @@ export function createUiActionRelay(
|
|
|
431
494
|
}
|
|
432
495
|
}
|
|
433
496
|
}
|
|
497
|
+
if (res.status === 404) return "miss";
|
|
434
498
|
if (!res.ok) return undefined;
|
|
435
499
|
try {
|
|
436
|
-
return (await res.json()) as unknown;
|
|
500
|
+
return { kind: "result", value: (await res.json()) as unknown };
|
|
437
501
|
} catch {
|
|
438
502
|
return undefined;
|
|
439
503
|
}
|
|
440
504
|
};
|
|
505
|
+
|
|
506
|
+
const podUrl = options.endpointUrl ? toUiActionUrl(options.endpointUrl) : null;
|
|
507
|
+
const callTool = async (
|
|
508
|
+
uri: string,
|
|
509
|
+
name: string,
|
|
510
|
+
args: McpToolStructuredContent | undefined,
|
|
511
|
+
): Promise<unknown> => {
|
|
512
|
+
// The kit sends only what the click carries; the pod overwrites any
|
|
513
|
+
// sessionId/appId from the authorized locator + its own binding.
|
|
514
|
+
const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
|
|
515
|
+
if (podUrl !== null) {
|
|
516
|
+
const live = await postDoor(podUrl, body);
|
|
517
|
+
if (live === undefined) return undefined; // terminal on the pod — no fall-through
|
|
518
|
+
if (live !== "miss") return live.value;
|
|
519
|
+
// 404 → not live here (completed turn past grace, or never live):
|
|
520
|
+
// the persisted door owns it.
|
|
521
|
+
}
|
|
522
|
+
const persisted = await postDoor(
|
|
523
|
+
`${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`,
|
|
524
|
+
body,
|
|
525
|
+
);
|
|
526
|
+
return persisted === undefined || persisted === "miss" ? undefined : persisted.value;
|
|
527
|
+
};
|
|
441
528
|
return createMcpUiActionRelay({ callTool });
|
|
442
529
|
}
|
|
530
|
+
|
|
531
|
+
/** `<pod base>/agent/hitl-answer` — the AgJSON HITL answer door (guuey#207). */
|
|
532
|
+
function toHitlAnswerUrl(endpointUrl: string): string {
|
|
533
|
+
return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/hitl-answer");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Options for {@link createHitlAnswerRelay} — the same credential surface as the card relays. */
|
|
537
|
+
export interface CreateHitlAnswerRelayOptions {
|
|
538
|
+
/** The surface's invoke endpoint (pod base URL or full `/agent/invoke` URL) — the answer door lives on the pod. */
|
|
539
|
+
endpointUrl: string;
|
|
540
|
+
/** Signed-in bearer — wins over the guest secret (same rule as the transport). */
|
|
541
|
+
getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
|
|
542
|
+
/** Caller-owned anonymous guest secret (widget / guest chat). */
|
|
543
|
+
guestSecret?: string | null;
|
|
544
|
+
/** Injectable for tests. */
|
|
545
|
+
fetchImpl?: typeof fetch;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The pod's answer to a delivered {@link AgHitlAnswer}. `ok` carries the
|
|
550
|
+
* body the door returns (`askId`, echoed `status`, and — for a recorded
|
|
551
|
+
* consent — the grant `mode` written); every non-2xx collapses to the pod's
|
|
552
|
+
* `{ code, message }` envelope (the same vocabulary as `AGENT_ERROR_CODES`,
|
|
553
|
+
* e.g. `NOT_FOUND` for an ask this pod did not mint, `INVALID_REQUEST` for a
|
|
554
|
+
* spec-invalid answer) with the HTTP status; a transport failure is
|
|
555
|
+
* `status: 0` with a null code.
|
|
556
|
+
*/
|
|
557
|
+
export type HitlAnswerRelayResult =
|
|
558
|
+
| { ok: true; body: { askId: string; status: AgHitlAnswer["status"]; mode?: string } }
|
|
559
|
+
| { ok: false; status: number; code: string | null; message: string };
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Build the client→pod channel for AgJSON HITL answers (guuey#207): `POST
|
|
563
|
+
* <pod>/agent/hitl-answer` with the spec {@link AgHitlAnswer} the kit's
|
|
564
|
+
* `answerHitlPrompt` constructed (already validated against the ask's
|
|
565
|
+
* persisted declaration). The pod owns EVERYTHING trust-shaped — caller
|
|
566
|
+
* identity (the same three families as the invoke), which ask it minted,
|
|
567
|
+
* the thread a `once` grant binds to, the access level written — this
|
|
568
|
+
* transport only carries the surface's existing credential under the
|
|
569
|
+
* one-carrier rule (bearer → guest header → cookie), with the card relays'
|
|
570
|
+
* single 401 forceRefresh retry.
|
|
571
|
+
*
|
|
572
|
+
* Today the only producer is the pod's cross-app profile consent ask (the
|
|
573
|
+
* three-mode grant), whose answer resolves into the caller's own
|
|
574
|
+
* `ProfileGrant` row; the channel is generic by construction — any future
|
|
575
|
+
* `hitl.ask` the runtime emits is answered through this same door.
|
|
576
|
+
*/
|
|
577
|
+
export function createHitlAnswerRelay(
|
|
578
|
+
options: CreateHitlAnswerRelayOptions,
|
|
579
|
+
): (answer: AgHitlAnswer) => Promise<HitlAnswerRelayResult> {
|
|
580
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
581
|
+
const url = toHitlAnswerUrl(options.endpointUrl);
|
|
582
|
+
return async (answer) => {
|
|
583
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
584
|
+
const body = JSON.stringify(answer);
|
|
585
|
+
const init: RequestInit = { method: "POST", headers, body };
|
|
586
|
+
const token = options.getAccessToken ? await options.getAccessToken() : null;
|
|
587
|
+
const guest = sendableGuestSecret(options.guestSecret);
|
|
588
|
+
if (token) {
|
|
589
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
590
|
+
} else if (guest) {
|
|
591
|
+
headers[GUEST_HEADER] = guest;
|
|
592
|
+
} else {
|
|
593
|
+
init.credentials = "include";
|
|
594
|
+
}
|
|
595
|
+
let res: Response;
|
|
596
|
+
try {
|
|
597
|
+
res = await fetchImpl(url, init);
|
|
598
|
+
} catch (err) {
|
|
599
|
+
return { ok: false, status: 0, code: null, message: err instanceof Error ? err.message : String(err) };
|
|
600
|
+
}
|
|
601
|
+
if (res.status === 401 && options.getAccessToken) {
|
|
602
|
+
const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
|
|
603
|
+
if (fresh) {
|
|
604
|
+
try {
|
|
605
|
+
res = await fetchImpl(url, {
|
|
606
|
+
method: "POST",
|
|
607
|
+
headers: { ...headers, authorization: `Bearer ${fresh}` },
|
|
608
|
+
body,
|
|
609
|
+
});
|
|
610
|
+
} catch (err) {
|
|
611
|
+
return { ok: false, status: 0, code: null, message: err instanceof Error ? err.message : String(err) };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
let parsed: unknown = undefined;
|
|
616
|
+
try {
|
|
617
|
+
parsed = await res.json();
|
|
618
|
+
} catch {
|
|
619
|
+
parsed = undefined;
|
|
620
|
+
}
|
|
621
|
+
if (res.ok) {
|
|
622
|
+
const b = (parsed ?? {}) as { askId?: unknown; status?: unknown; mode?: unknown };
|
|
623
|
+
return {
|
|
624
|
+
ok: true,
|
|
625
|
+
body: {
|
|
626
|
+
askId: typeof b.askId === "string" ? b.askId : answer.askId,
|
|
627
|
+
status: b.status === "resolved" || b.status === "declined" || b.status === "cancelled" ? b.status : answer.status,
|
|
628
|
+
...(typeof b.mode === "string" ? { mode: b.mode } : {}),
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const env = (parsed ?? {}) as { code?: unknown; message?: unknown };
|
|
633
|
+
return {
|
|
634
|
+
ok: false,
|
|
635
|
+
status: res.status,
|
|
636
|
+
code: typeof env.code === "string" ? env.code : null,
|
|
637
|
+
message: typeof env.message === "string" ? env.message : `hitl-answer failed (${res.status})`,
|
|
638
|
+
};
|
|
639
|
+
};
|
|
640
|
+
}
|