@guuey/agent-client 0.4.0 → 0.6.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 +39 -5
- package/dist/error-codes.d.ts +29 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +27 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -9
- package/dist/invoke-turn.d.ts +101 -0
- package/dist/invoke-turn.d.ts.map +1 -0
- package/dist/invoke-turn.js +124 -0
- 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 +41 -0
- package/dist/saturation-retry.d.ts.map +1 -1
- package/dist/saturation-retry.js +78 -6
- package/dist/transport.d.ts +90 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +189 -0
- package/dist/types.d.ts +73 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/useAgentInvoke.d.ts +28 -0
- package/dist/useAgentInvoke.d.ts.map +1 -1
- package/dist/useAgentInvoke.js +281 -90
- package/dist/web-adapters.d.ts +1 -11
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +9 -121
- package/package.json +9 -3
- package/src/error-codes.ts +31 -0
- package/src/index.ts +33 -9
- package/src/invoke-turn.ts +187 -0
- package/src/react.ts +7 -1
- package/src/saturation-retry.ts +103 -6
- package/src/transport.ts +260 -0
- package/src/types.ts +74 -1
- package/src/useAgentInvoke.ts +271 -89
- package/src/web-adapters.ts +10 -150
package/dist/useAgentInvoke.js
CHANGED
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
25
25
|
import { Reducer } from "@silverprotocol/core";
|
|
26
|
-
import {
|
|
27
|
-
import { ingestMessageFrame } from "./blocks.js";
|
|
26
|
+
import { invokeTurn, toInvokeUrl } from "./invoke-turn.js";
|
|
28
27
|
import { AgentResponseError } from "./errors.js";
|
|
28
|
+
import { withActivityObserver } from "./transport.js";
|
|
29
|
+
import { CLIENT_ERROR_CODES } from "./error-codes.js";
|
|
29
30
|
function threadStorageKey(appId) {
|
|
30
31
|
return `guuey:thread:${appId ?? "default"}`;
|
|
31
32
|
}
|
|
@@ -43,6 +44,50 @@ export function applyHistoryResult(result, currentMessages) {
|
|
|
43
44
|
return { kind: "skip" };
|
|
44
45
|
return { kind: "seed", messages: result.messages };
|
|
45
46
|
}
|
|
47
|
+
/** The guuey#192 stall watchdog's resolved tuning (see {@link stallProbeDecision}). */
|
|
48
|
+
export const STALL_RECOVERY_DEFAULTS = { windowMs: 25_000, probeAttempts: 4 };
|
|
49
|
+
function resolveStallRecovery(option) {
|
|
50
|
+
if (option === false)
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
windowMs: option?.windowMs ?? STALL_RECOVERY_DEFAULTS.windowMs,
|
|
54
|
+
probeAttempts: option?.probeAttempts ?? STALL_RECOVERY_DEFAULTS.probeAttempts,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Pure decision seam for the guuey#192 stall probe: does a freshly-loaded
|
|
59
|
+
* transcript already contain THIS turn's finished reply?
|
|
60
|
+
*
|
|
61
|
+
* `adopt` requires BOTH signals, because each alone lies in a real case:
|
|
62
|
+
*
|
|
63
|
+
* - **user-count**: history must hold at least as many user turns as the
|
|
64
|
+
* local transcript (which includes the just-sent optimistic one). Without
|
|
65
|
+
* it, a thread whose PREVIOUS turn ended in a completed assistant reply
|
|
66
|
+
* would adopt that OLD transcript and silently drop the in-flight turn.
|
|
67
|
+
* - **finished tail**: history's last message must be a non-empty assistant
|
|
68
|
+
* reply. Without it, a history read that caught the persisted user row
|
|
69
|
+
* before the assistant row would adopt a reply-less transcript.
|
|
70
|
+
*
|
|
71
|
+
* KNOWN LIMIT (documented, accepted): the runtime persists a turn's rows at
|
|
72
|
+
* completion — the guuey#192 evidence (a reload mid-stall renders the FULL
|
|
73
|
+
* reply) is only possible under that model, and the read plane carries no
|
|
74
|
+
* per-row clientMessageId to match against. If persistence ever becomes
|
|
75
|
+
* progressive (partial assistant rows), this heuristic needs the read plane
|
|
76
|
+
* to grow a turn-completion marker — do not "fix" it client-side by text
|
|
77
|
+
* comparison, which cannot distinguish a partial row from a finished one.
|
|
78
|
+
*/
|
|
79
|
+
export function stallProbeDecision(history, localUserCount) {
|
|
80
|
+
let historyUserCount = 0;
|
|
81
|
+
for (const m of history)
|
|
82
|
+
if (m.role === "user")
|
|
83
|
+
historyUserCount += 1;
|
|
84
|
+
if (historyUserCount < localUserCount)
|
|
85
|
+
return "in-flight";
|
|
86
|
+
const last = history[history.length - 1];
|
|
87
|
+
if (!last || last.role !== "assistant" || last.text.trim() === "")
|
|
88
|
+
return "in-flight";
|
|
89
|
+
return "adopt";
|
|
90
|
+
}
|
|
46
91
|
export function useAgentInvoke(opts) {
|
|
47
92
|
const { endpointUrl, appId } = opts;
|
|
48
93
|
const [messages, setMessages] = useState([]);
|
|
@@ -75,6 +120,13 @@ export function useAgentInvoke(opts) {
|
|
|
75
120
|
// explicit dismiss, same lifecycle as `profileConsentRequest` — the two are
|
|
76
121
|
// independent (an unlinked-invite vs an already-linked consent ask).
|
|
77
122
|
const [profileLinkRequest, setProfileLinkRequest] = useState(null);
|
|
123
|
+
// The last turn's ending posture + the optimistic-send ledger — the
|
|
124
|
+
// transcript renderer's inputs (guuey#135 wave 3b; see the return-type
|
|
125
|
+
// contract for each). `aborted` is USER abort only — the #192 watchdog's
|
|
126
|
+
// internal stream abort never sets it.
|
|
127
|
+
const [aborted, setAborted] = useState(false);
|
|
128
|
+
const [adopted, setAdopted] = useState(false);
|
|
129
|
+
const [sendStates, setSendStates] = useState({});
|
|
78
130
|
const abortRef = useRef(null);
|
|
79
131
|
// Mirror the latest threadId + adapters into refs so `send` reads fresh
|
|
80
132
|
// values without depending on them (keeps the callback identity stable and
|
|
@@ -82,6 +134,13 @@ export function useAgentInvoke(opts) {
|
|
|
82
134
|
const threadIdRef = useRef(null);
|
|
83
135
|
const adaptersRef = useRef(opts.adapters);
|
|
84
136
|
adaptersRef.current = opts.adapters;
|
|
137
|
+
// The stall probe (guuey#192) needs the committed transcript's user-turn
|
|
138
|
+
// count long after `send`'s closures captured state — same render-time
|
|
139
|
+
// mirror idiom as `adaptersRef`.
|
|
140
|
+
const messagesRef = useRef(messages);
|
|
141
|
+
messagesRef.current = messages;
|
|
142
|
+
const stallRecoveryRef = useRef(opts.stallRecovery);
|
|
143
|
+
stallRecoveryRef.current = opts.stallRecovery;
|
|
85
144
|
// The per-conversation AgJSON fold (only built when `preserveBlocks`).
|
|
86
145
|
// Lazily (re)created on the first valid AgEvent after a fresh start / reset,
|
|
87
146
|
// so an off run never constructs one and a bypass run never allocates.
|
|
@@ -118,6 +177,9 @@ export function useAgentInvoke(opts) {
|
|
|
118
177
|
// A prior app's consent ask must never leak into the new conversation.
|
|
119
178
|
setProfileConsentRequest(null);
|
|
120
179
|
setProfileLinkRequest(null);
|
|
180
|
+
setAborted(false);
|
|
181
|
+
setAdopted(false);
|
|
182
|
+
setSendStates({});
|
|
121
183
|
let cancelled = false;
|
|
122
184
|
const key = threadStorageKey(appId);
|
|
123
185
|
const hydration = Promise.resolve(adaptersRef.current.storage.load(key))
|
|
@@ -208,6 +270,9 @@ export function useAgentInvoke(opts) {
|
|
|
208
270
|
setHistoryCards([]);
|
|
209
271
|
setProfileConsentRequest(null);
|
|
210
272
|
setProfileLinkRequest(null);
|
|
273
|
+
setAborted(false);
|
|
274
|
+
setAdopted(false);
|
|
275
|
+
setSendStates({});
|
|
211
276
|
}, [appId]);
|
|
212
277
|
const clearProfileConsentRequest = useCallback(() => {
|
|
213
278
|
setProfileConsentRequest(null);
|
|
@@ -216,14 +281,49 @@ export function useAgentInvoke(opts) {
|
|
|
216
281
|
setProfileLinkRequest(null);
|
|
217
282
|
}, []);
|
|
218
283
|
const send = useCallback(async (input) => {
|
|
219
|
-
|
|
284
|
+
// An already-aborted external signal refuses the send outright —
|
|
285
|
+
// before the optimistic transcript push, so nothing is left to undo.
|
|
286
|
+
if (!endpointUrl || !input.trim() || status !== "ready" || opts.signal?.aborted)
|
|
220
287
|
return;
|
|
221
288
|
setError(null);
|
|
222
289
|
setErrorCode(null);
|
|
290
|
+
setAborted(false);
|
|
291
|
+
setAdopted(false);
|
|
223
292
|
setStatus("connecting");
|
|
224
|
-
|
|
293
|
+
// ONE id for the whole turn: the optimistic user entry, the send-state
|
|
294
|
+
// ledger, and the invoke body all carry it — the R0 lifecycle join.
|
|
295
|
+
const clientMessageId = adaptersRef.current.generateId();
|
|
296
|
+
/** Move this turn's ledger entry; `null` removes it (absent = sent). */
|
|
297
|
+
const markSend = (state) => {
|
|
298
|
+
setSendStates((prev) => {
|
|
299
|
+
if (state === null) {
|
|
300
|
+
if (!(clientMessageId in prev))
|
|
301
|
+
return prev;
|
|
302
|
+
const next = { ...prev };
|
|
303
|
+
delete next[clientMessageId];
|
|
304
|
+
return next;
|
|
305
|
+
}
|
|
306
|
+
if (prev[clientMessageId] === state)
|
|
307
|
+
return prev;
|
|
308
|
+
return { ...prev, [clientMessageId]: state };
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
markSend("sending");
|
|
312
|
+
let admitted = false;
|
|
313
|
+
setMessages((prev) => [
|
|
314
|
+
...prev,
|
|
315
|
+
{ role: "user", text: input, clientMessageId },
|
|
316
|
+
{ role: "assistant", text: "" },
|
|
317
|
+
]);
|
|
225
318
|
const controller = new AbortController();
|
|
226
319
|
abortRef.current = controller;
|
|
320
|
+
// Compose the host's external abort authority (opts.signal) with the
|
|
321
|
+
// per-turn controller: an external abort stops this turn exactly as
|
|
322
|
+
// `abort()` would. Listener removed in `finally` — the signal outlives
|
|
323
|
+
// the turn, the subscription must not.
|
|
324
|
+
const externalSignal = opts.signal;
|
|
325
|
+
const onExternalAbort = () => controller.abort();
|
|
326
|
+
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
227
327
|
const adapters = adaptersRef.current;
|
|
228
328
|
// Wait for the persisted threadId to load before deciding whether to
|
|
229
329
|
// replay it — otherwise a fast first send mints a new orphan thread and
|
|
@@ -246,99 +346,165 @@ export function useAgentInvoke(opts) {
|
|
|
246
346
|
return next;
|
|
247
347
|
});
|
|
248
348
|
};
|
|
349
|
+
// ── guuey#192 stall watchdog ─────────────────────────────────────
|
|
350
|
+
// A half-dead connection (TCP alive, zero bytes, no error, no `done`)
|
|
351
|
+
// never resolves the read below, so a parallel clock watches byte
|
|
352
|
+
// activity: armed by the FIRST chunk (so a silent cold start never
|
|
353
|
+
// trips it), reset by every chunk, and on expiry it probes history
|
|
354
|
+
// WITHOUT touching the stream — killing a live-but-quiet stream on a
|
|
355
|
+
// timer would trade a frozen cursor for a lost turn. Only two things
|
|
356
|
+
// end the turn early: adoption (history already holds the finished
|
|
357
|
+
// reply — the reload the user would have done, minus the reload) and
|
|
358
|
+
// the bounded give-up (STREAM_STALLED after `probeAttempts` fruitless
|
|
359
|
+
// probes with still-zero bytes).
|
|
360
|
+
const stall = resolveStallRecovery(stallRecoveryRef.current);
|
|
361
|
+
let turnEnded = false;
|
|
362
|
+
let probeInFlight = false;
|
|
363
|
+
let fruitlessProbes = 0;
|
|
364
|
+
let activityCount = 0;
|
|
365
|
+
let stallTimer = null;
|
|
366
|
+
const clearStallTimer = () => {
|
|
367
|
+
if (stallTimer !== null) {
|
|
368
|
+
clearTimeout(stallTimer);
|
|
369
|
+
stallTimer = null;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
const armStallTimer = () => {
|
|
373
|
+
if (!stall || turnEnded || controller.signal.aborted)
|
|
374
|
+
return;
|
|
375
|
+
clearStallTimer();
|
|
376
|
+
stallTimer = setTimeout(() => {
|
|
377
|
+
void onStallWindow();
|
|
378
|
+
}, stall.windowMs);
|
|
379
|
+
};
|
|
380
|
+
const endTurnWith = (apply) => {
|
|
381
|
+
turnEnded = true;
|
|
382
|
+
clearStallTimer();
|
|
383
|
+
apply();
|
|
384
|
+
// Unwinds the suspended read; the catch sees `aborted` and stays
|
|
385
|
+
// silent, so whatever `apply` decided IS the turn's outcome.
|
|
386
|
+
controller.abort();
|
|
387
|
+
};
|
|
388
|
+
const onStallWindow = async () => {
|
|
389
|
+
if (!stall || turnEnded || controller.signal.aborted || probeInFlight)
|
|
390
|
+
return;
|
|
391
|
+
const tid = threadIdRef.current;
|
|
392
|
+
const history = adaptersRef.current.history;
|
|
393
|
+
if (tid && history) {
|
|
394
|
+
probeInFlight = true;
|
|
395
|
+
const countAtProbe = activityCount;
|
|
396
|
+
let result = null;
|
|
397
|
+
try {
|
|
398
|
+
result = await history.load(tid);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
result = null; // transient read failure = one fruitless probe
|
|
402
|
+
}
|
|
403
|
+
probeInFlight = false;
|
|
404
|
+
if (turnEnded || controller.signal.aborted)
|
|
405
|
+
return;
|
|
406
|
+
// Bytes resumed while the probe was in flight: the stream is alive
|
|
407
|
+
// — discard the now-stale read; the chunk observer already reset
|
|
408
|
+
// the count and re-armed the clock.
|
|
409
|
+
if (activityCount !== countAtProbe)
|
|
410
|
+
return;
|
|
411
|
+
if (result && !("gone" in result)) {
|
|
412
|
+
let localUserCount = 0;
|
|
413
|
+
for (const m of messagesRef.current)
|
|
414
|
+
if (m.role === "user")
|
|
415
|
+
localUserCount += 1;
|
|
416
|
+
if (stallProbeDecision(result.messages, localUserCount) === "adopt") {
|
|
417
|
+
const adoptedResult = result;
|
|
418
|
+
endTurnWith(() => {
|
|
419
|
+
setMessages(adoptedResult.messages);
|
|
420
|
+
if ("cards" in adoptedResult && adoptedResult.cards && adoptedResult.cards.length > 0) {
|
|
421
|
+
setHistoryCards(adoptedResult.cards);
|
|
422
|
+
}
|
|
423
|
+
// The renderer's #192 signal: calm renders the adopted turn
|
|
424
|
+
// identically; debug may mark it (guuey#135 3b).
|
|
425
|
+
setAdopted(true);
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// No probe possible (no threadId yet / no history adapter), a failed
|
|
432
|
+
// read, or history says the turn is still in flight — all count the
|
|
433
|
+
// same: one fruitless window.
|
|
434
|
+
fruitlessProbes += 1;
|
|
435
|
+
if (fruitlessProbes >= stall.probeAttempts) {
|
|
436
|
+
endTurnWith(() => {
|
|
437
|
+
setError("The response stream stalled and the finished reply was not found in history.");
|
|
438
|
+
setErrorCode(CLIENT_ERROR_CODES.STREAM_STALLED);
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
armStallTimer();
|
|
443
|
+
};
|
|
444
|
+
const transport = stall
|
|
445
|
+
? withActivityObserver(adapters.transport, () => {
|
|
446
|
+
activityCount += 1;
|
|
447
|
+
fruitlessProbes = 0;
|
|
448
|
+
armStallTimer();
|
|
449
|
+
})
|
|
450
|
+
: adapters.transport;
|
|
249
451
|
try {
|
|
250
|
-
|
|
251
|
-
// invoke URL the deploy-controller records (`https://host/agent/invoke`).
|
|
252
|
-
// Normalize to exactly one `/agent/invoke`.
|
|
253
|
-
const base = endpointUrl.replace(/\/+$/, "");
|
|
254
|
-
const invokeUrl = base.endsWith("/agent/invoke") ? base : `${base}/agent/invoke`;
|
|
452
|
+
const invokeUrl = toInvokeUrl(endpointUrl);
|
|
255
453
|
const body = {
|
|
256
454
|
input,
|
|
257
455
|
...(threadIdRef.current ? { threadId: threadIdRef.current } : {}),
|
|
258
|
-
clientMessageId
|
|
456
|
+
clientMessageId,
|
|
259
457
|
};
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
setThreadId(tid);
|
|
275
|
-
void adapters.storage.save(threadStorageKey(appId), tid);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
else if (ev.event === "message") {
|
|
279
|
-
// Status derivation (guuey#91) — read the frame's `type` before
|
|
280
|
-
// the text fold. Silver frames announce tools + text explicitly;
|
|
281
|
-
// bypass frames ('text' / 'assistant' SDKMessages) only ever
|
|
282
|
-
// carry assistant text, so they map to 'responding'. Unknown
|
|
283
|
-
// types deliberately leave the status untouched.
|
|
284
|
-
const frameType = stringField(ev.data, "type");
|
|
285
|
-
if (frameType === "tool.start") {
|
|
286
|
-
setStatus("using-tool");
|
|
287
|
-
setActiveTool(stringField(ev.data, "name") ?? null);
|
|
288
|
-
}
|
|
289
|
-
else if (frameType === "tool.done") {
|
|
290
|
-
setStatus("thinking");
|
|
291
|
-
setActiveTool(null);
|
|
292
|
-
}
|
|
293
|
-
else if (frameType === "text.start" ||
|
|
294
|
-
frameType === "text.delta" ||
|
|
295
|
-
frameType === "text" ||
|
|
296
|
-
frameType === "assistant") {
|
|
297
|
-
setStatus("responding");
|
|
298
|
-
}
|
|
299
|
-
renderAssistant(reduceAssistantText(assistantText, ev.data));
|
|
300
|
-
// Additively fold the SAME frame into the AgJSON reducer when
|
|
301
|
-
// opted in. The text surface above is untouched; only VALID
|
|
302
|
-
// AgEvents advance the reducer (bypass frames ingest to [] and
|
|
303
|
-
// leave `reduceResult` null — see the return-type contract).
|
|
304
|
-
if (preserveBlocksRef.current) {
|
|
305
|
-
const agEvents = ingestMessageFrame(ev.data);
|
|
306
|
-
if (agEvents.length > 0) {
|
|
307
|
-
if (!reducerRef.current)
|
|
308
|
-
reducerRef.current = new Reducer();
|
|
309
|
-
for (const agEvent of agEvents)
|
|
310
|
-
reducerRef.current.push(agEvent);
|
|
311
|
-
setReduceResult(reducerRef.current.result());
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
else if (ev.event === "error") {
|
|
316
|
-
// In-band failure frame — one of the two channels that carry the
|
|
317
|
-
// pod's wire code (the other is the pre-stream refusal caught
|
|
318
|
-
// below). A frame without a `code` clears it rather than leaving
|
|
319
|
-
// a previous turn's code standing beside a new message.
|
|
320
|
-
setError(stringField(ev.data, "message") ?? "agent error");
|
|
321
|
-
setErrorCode(stringField(ev.data, "code") ?? null);
|
|
322
|
-
}
|
|
323
|
-
else if (ev.event === "profile-consent-needed") {
|
|
324
|
-
// Cross-app profile consent ask (T6). Only a well-formed payload
|
|
325
|
-
// updates state; a malformed one is dropped, leaving any prior
|
|
326
|
-
// valid request untouched (never clobbered to null).
|
|
327
|
-
const parsed = parseConsentRequest(ev.data);
|
|
328
|
-
if (parsed)
|
|
329
|
-
setProfileConsentRequest(parsed);
|
|
458
|
+
// The wire walk lives in `invokeTurn` (the pure per-turn generator —
|
|
459
|
+
// its docblock owns the switch semantics); this hook only maps each
|
|
460
|
+
// semantic event onto React state.
|
|
461
|
+
for await (const ev of invokeTurn({ url: invokeUrl, body, signal: controller.signal }, transport)) {
|
|
462
|
+
if (ev.kind === "session") {
|
|
463
|
+
// The pod is awake and the turn is admitted — 'connecting' ends
|
|
464
|
+
// here, and so does the R0 "sending" state (absent = sent).
|
|
465
|
+
admitted = true;
|
|
466
|
+
markSend(null);
|
|
467
|
+
setStatus("thinking");
|
|
468
|
+
if (ev.threadId) {
|
|
469
|
+
threadIdRef.current = ev.threadId;
|
|
470
|
+
setThreadId(ev.threadId);
|
|
471
|
+
void adapters.storage.save(threadStorageKey(appId), ev.threadId);
|
|
330
472
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
473
|
+
}
|
|
474
|
+
else if (ev.kind === "message") {
|
|
475
|
+
// Absent status/activeTool mean "no change" — never touched, so
|
|
476
|
+
// an unknown frame type leaves both standing (guuey#91 rule).
|
|
477
|
+
if (ev.status !== undefined)
|
|
478
|
+
setStatus(ev.status);
|
|
479
|
+
if (ev.activeTool !== undefined)
|
|
480
|
+
setActiveTool(ev.activeTool);
|
|
481
|
+
renderAssistant(ev.assistantText);
|
|
482
|
+
// Additively fold the frame's AgEvents into the AgJSON reducer
|
|
483
|
+
// when opted in. The text surface above is untouched; only VALID
|
|
484
|
+
// AgEvents advance the reducer (bypass frames carry [] and leave
|
|
485
|
+
// `reduceResult` null — see the return-type contract).
|
|
486
|
+
if (preserveBlocksRef.current && ev.agEvents.length > 0) {
|
|
487
|
+
if (!reducerRef.current)
|
|
488
|
+
reducerRef.current = new Reducer();
|
|
489
|
+
for (const agEvent of ev.agEvents)
|
|
490
|
+
reducerRef.current.push(agEvent);
|
|
491
|
+
setReduceResult(reducerRef.current.result());
|
|
337
492
|
}
|
|
338
|
-
// `done` needs no handling — the stream closes after it. Any other
|
|
339
|
-
// (unknown) event falls through silently — there is no default
|
|
340
|
-
// branch, so a consumer that never renders a field is unaffected.
|
|
341
493
|
}
|
|
494
|
+
else if (ev.kind === "error") {
|
|
495
|
+
// In-band failure frame — the code moves in lockstep with the
|
|
496
|
+
// message (an event without one carries null rather than leaving
|
|
497
|
+
// a previous turn's code standing).
|
|
498
|
+
setError(ev.message);
|
|
499
|
+
setErrorCode(ev.code);
|
|
500
|
+
}
|
|
501
|
+
else if (ev.kind === "profile-consent") {
|
|
502
|
+
setProfileConsentRequest(ev.request);
|
|
503
|
+
}
|
|
504
|
+
else if (ev.kind === "profile-link") {
|
|
505
|
+
setProfileLinkRequest(ev.request);
|
|
506
|
+
}
|
|
507
|
+
// `done` needs no handling here — the stream closes after it.
|
|
342
508
|
}
|
|
343
509
|
}
|
|
344
510
|
catch (e) {
|
|
@@ -350,9 +516,23 @@ export function useAgentInvoke(opts) {
|
|
|
350
516
|
// other throw — a network drop, a host-adapter failure — has no wire
|
|
351
517
|
// code, so the field stays null beside the message.
|
|
352
518
|
setErrorCode(e instanceof AgentResponseError ? (e.code ?? null) : null);
|
|
519
|
+
// A failure BEFORE admission means the message never reached the
|
|
520
|
+
// agent — the R0 failed-to-send state. Post-admission failures
|
|
521
|
+
// leave the entry removed (the send itself succeeded).
|
|
522
|
+
if (!admitted)
|
|
523
|
+
markSend("failed");
|
|
353
524
|
}
|
|
354
525
|
}
|
|
355
526
|
finally {
|
|
527
|
+
// The turn is over however it ended — no probe may fire after this,
|
|
528
|
+
// and the pending timer must not leak past the turn. `turnEnded`
|
|
529
|
+
// already true here ⟺ the #192 watchdog ended the turn (adoption or
|
|
530
|
+
// stall give-up) — its internal `controller.abort()` must not read
|
|
531
|
+
// as a USER abort below.
|
|
532
|
+
const endedByWatchdog = turnEnded;
|
|
533
|
+
turnEnded = true;
|
|
534
|
+
clearStallTimer();
|
|
535
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
356
536
|
setStatus("ready");
|
|
357
537
|
setActiveTool(null);
|
|
358
538
|
abortRef.current = null;
|
|
@@ -367,8 +547,16 @@ export function useAgentInvoke(opts) {
|
|
|
367
547
|
: prev;
|
|
368
548
|
});
|
|
369
549
|
}
|
|
550
|
+
if (controller.signal.aborted && !endedByWatchdog) {
|
|
551
|
+
// USER abort (abort() or the external signal): surface it, and a
|
|
552
|
+
// pre-admission cancel clears the "sending" entry — a turn the
|
|
553
|
+
// user stopped is not a failed send.
|
|
554
|
+
setAborted(true);
|
|
555
|
+
if (!admitted)
|
|
556
|
+
markSend(null);
|
|
557
|
+
}
|
|
370
558
|
}
|
|
371
|
-
}, [endpointUrl, appId, status]);
|
|
559
|
+
}, [endpointUrl, appId, status, opts.signal]);
|
|
372
560
|
return {
|
|
373
561
|
messages,
|
|
374
562
|
send,
|
|
@@ -385,5 +573,8 @@ export function useAgentInvoke(opts) {
|
|
|
385
573
|
clearProfileConsentRequest,
|
|
386
574
|
profileLinkRequest,
|
|
387
575
|
clearProfileLinkRequest,
|
|
576
|
+
aborted,
|
|
577
|
+
adopted,
|
|
578
|
+
sendStates,
|
|
388
579
|
};
|
|
389
580
|
}
|
package/dist/web-adapters.d.ts
CHANGED
|
@@ -7,21 +7,11 @@
|
|
|
7
7
|
* (the functions guard on `typeof window`).
|
|
8
8
|
*/
|
|
9
9
|
import { type McpToolCallResult, type ResolvedViewMount, type UiActionRequest } from "@guuey/mcp-apps-host";
|
|
10
|
-
import type { AgentInvokeAdapters,
|
|
11
|
-
import { type SaturationRetryOptions } from "./saturation-retry.js";
|
|
10
|
+
import type { AgentInvokeAdapters, ThreadIdStore } from "./types.js";
|
|
12
11
|
/** Persists the threadId in `window.localStorage` (synchronously). */
|
|
13
12
|
export declare const localStorageThreadStore: ThreadIdStore;
|
|
14
13
|
/** Crypto-strong client-message id, with a non-crypto fallback. */
|
|
15
14
|
export declare function webGenerateId(): string;
|
|
16
|
-
/**
|
|
17
|
-
* The web SSE transport: {@link streamInvokeOnce} under the shared
|
|
18
|
-
* {@link withSaturationRetry} wrapper. Every consumer of this transport
|
|
19
|
-
* (Studio, the widget, anything built on {@link createWebAdapters}) therefore
|
|
20
|
-
* inherits the single `POD_SATURATED` retry, and inherits the SAME one Portal's
|
|
21
|
-
* React-Native transport wears — see that wrapper's docblock for which refusals
|
|
22
|
-
* retry, which deliberately do not, and why the retry is invisible to the hook.
|
|
23
|
-
*/
|
|
24
|
-
export declare function fetchStreamTransport(req: InvokeRequest, accessToken?: string | null, guestSecret?: string | null, options?: SaturationRetryOptions): AsyncIterable<string>;
|
|
25
15
|
export interface CreateWebAdaptersOptions {
|
|
26
16
|
/**
|
|
27
17
|
* Public read-plane base (ending in `/v1`) for transcript history. When
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAItF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA8ErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAoDjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA+C1D"}
|
package/dist/web-adapters.js
CHANGED
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createMcpUiActionRelay, createMcpUiResourceReader, } from "@guuey/mcp-apps-host";
|
|
10
10
|
import { fetchThreadHistory, HistoryUnauthorizedError } from "./history.js";
|
|
11
|
-
import {
|
|
12
|
-
import { parseRetryAfterSeconds, withSaturationRetry, } from "./saturation-retry.js";
|
|
11
|
+
import { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER } from "./transport.js";
|
|
13
12
|
/** Persists the threadId in `window.localStorage` (synchronously). */
|
|
14
13
|
export const localStorageThreadStore = {
|
|
15
14
|
load(key) {
|
|
@@ -40,123 +39,6 @@ export function webGenerateId() {
|
|
|
40
39
|
}
|
|
41
40
|
return `cmid-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
|
42
41
|
}
|
|
43
|
-
/**
|
|
44
|
-
* Header carrying a caller-owned anonymous guest secret. A LOCAL MIRROR of the
|
|
45
|
-
* two server-side constants — the pod's `GUEST_HEADER_NAME`
|
|
46
|
-
* (`backend/services/nocode-runtime/src/identity.ts`) and the read plane's
|
|
47
|
-
* `GUEST_HEADER` (`backend/amplify/functions/publicApi/identity.ts`) — because
|
|
48
|
-
* this is a published npm package and cannot take a `@guuey-private` dep (same
|
|
49
|
-
* arrangement as `@guuey/host`'s mirrored fs-contract constants). The string is
|
|
50
|
-
* a wire contract: both planes already advertise it in
|
|
51
|
-
* `Access-Control-Allow-Headers`, so changing it is a breaking protocol change,
|
|
52
|
-
* not a rename.
|
|
53
|
-
*/
|
|
54
|
-
const GUEST_HEADER = "x-guuey-guest";
|
|
55
|
-
/**
|
|
56
|
-
* A well-formed guest secret: exactly 32 bytes as 64 LOWERCASE hex chars —
|
|
57
|
-
* the shape `crypto.getRandomValues` + hex-encoding mints.
|
|
58
|
-
*
|
|
59
|
-
* Deliberately stricter than the server's `/^[a-f0-9]{64}$/i` (pod
|
|
60
|
-
* `identity.ts`, publicApi `identity.ts`): both sides lowercase before
|
|
61
|
-
* hashing, so an uppercase secret would in fact be accepted, but the only
|
|
62
|
-
* supported mint path emits lowercase and a non-canonical value means the
|
|
63
|
-
* caller's storage is not what this adapter expects. Anything that fails is
|
|
64
|
-
* IGNORED — the request falls through to cookie mode rather than sending a
|
|
65
|
-
* secret the two identity planes might key differently.
|
|
66
|
-
*/
|
|
67
|
-
const GUEST_SECRET_RE = /^[0-9a-f]{64}$/;
|
|
68
|
-
/**
|
|
69
|
-
* Narrow a caller-supplied guest secret to a value that is safe to put on the
|
|
70
|
-
* wire, or `null`. The single gate for the header: every write of
|
|
71
|
-
* {@link GUEST_HEADER} in this module goes through it, so a malformed secret
|
|
72
|
-
* can never reach a request. The value is never logged (here or anywhere on
|
|
73
|
-
* this path) — it IS the anonymous identity, so a leak is an impersonation.
|
|
74
|
-
*/
|
|
75
|
-
function sendableGuestSecret(secret) {
|
|
76
|
-
return typeof secret === "string" && GUEST_SECRET_RE.test(secret) ? secret : null;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* One invoke attempt: opens the request and yields decoded SSE chunks.
|
|
80
|
-
* {@link fetchStreamTransport} wraps this with the shared saturation retry —
|
|
81
|
-
* every behaviour below is per-attempt.
|
|
82
|
-
*
|
|
83
|
-
* Exactly ONE identity carrier per request, in order:
|
|
84
|
-
*
|
|
85
|
-
* 1. `accessToken` → `Authorization: Bearer` — the pod identifies the caller
|
|
86
|
-
* by their verified access token (the same identity the history read
|
|
87
|
-
* plane uses, so persisted threads round-trip on reload).
|
|
88
|
-
* 2. a well-formed `guestSecret` → `x-guuey-guest` — the caller owns and
|
|
89
|
-
* persists its own anonymous secret. The path for hosts with no usable
|
|
90
|
-
* cookie jar: React-Native, and the embedded widget, whose third-party
|
|
91
|
-
* iframe cannot rely on the pod's cookie surviving browser partitioning.
|
|
92
|
-
* The pod never mints a cookie for a header client.
|
|
93
|
-
* 3. neither → `credentials: "include"`, which round-trips the HttpOnly
|
|
94
|
-
* `guuey_guest` cookie the pod mints for anonymous browser callers.
|
|
95
|
-
*
|
|
96
|
-
* Never two at once: a bearer wins over a guest secret, and a request that
|
|
97
|
-
* carries either header does NOT also send cookie credentials.
|
|
98
|
-
*
|
|
99
|
-
* Reads the body via `ReadableStream.getReader()` (browser).
|
|
100
|
-
*/
|
|
101
|
-
async function* streamInvokeOnce(req, accessToken, guestSecret) {
|
|
102
|
-
const headers = {
|
|
103
|
-
"Content-Type": "application/json",
|
|
104
|
-
Accept: "text/event-stream",
|
|
105
|
-
};
|
|
106
|
-
const init = {
|
|
107
|
-
method: "POST",
|
|
108
|
-
signal: req.signal,
|
|
109
|
-
headers,
|
|
110
|
-
body: JSON.stringify(req.body),
|
|
111
|
-
};
|
|
112
|
-
const guest = sendableGuestSecret(guestSecret);
|
|
113
|
-
if (accessToken) {
|
|
114
|
-
headers.Authorization = `Bearer ${accessToken}`;
|
|
115
|
-
}
|
|
116
|
-
else if (guest) {
|
|
117
|
-
headers[GUEST_HEADER] = guest;
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
init.credentials = "include";
|
|
121
|
-
}
|
|
122
|
-
const resp = await fetch(req.url, init);
|
|
123
|
-
if (!resp.ok || !resp.body) {
|
|
124
|
-
// Surface a structured pod error ({ code, message }) when present — e.g. a
|
|
125
|
-
// QUOTA_EXCEEDED 429 carries an upgrade message the UI should show. Fall
|
|
126
|
-
// back to the bare status for non-JSON failures.
|
|
127
|
-
const body = await resp.json().catch(() => null);
|
|
128
|
-
let message = `agent responded ${resp.status}`;
|
|
129
|
-
let code;
|
|
130
|
-
if (body !== null && typeof body === "object") {
|
|
131
|
-
if ("message" in body && typeof body.message === "string" && body.message) {
|
|
132
|
-
message = body.message;
|
|
133
|
-
}
|
|
134
|
-
if ("code" in body && typeof body.code === "string") {
|
|
135
|
-
code = body.code;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
throw new AgentResponseError(message, resp.status, code, parseRetryAfterSeconds(resp.headers.get("Retry-After")));
|
|
139
|
-
}
|
|
140
|
-
const reader = resp.body.getReader();
|
|
141
|
-
const decoder = new TextDecoder();
|
|
142
|
-
for (;;) {
|
|
143
|
-
const { value, done } = await reader.read();
|
|
144
|
-
if (done)
|
|
145
|
-
break;
|
|
146
|
-
yield decoder.decode(value, { stream: true });
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* The web SSE transport: {@link streamInvokeOnce} under the shared
|
|
151
|
-
* {@link withSaturationRetry} wrapper. Every consumer of this transport
|
|
152
|
-
* (Studio, the widget, anything built on {@link createWebAdapters}) therefore
|
|
153
|
-
* inherits the single `POD_SATURATED` retry, and inherits the SAME one Portal's
|
|
154
|
-
* React-Native transport wears — see that wrapper's docblock for which refusals
|
|
155
|
-
* retry, which deliberately do not, and why the retry is invisible to the hook.
|
|
156
|
-
*/
|
|
157
|
-
export function fetchStreamTransport(req, accessToken, guestSecret, options = {}) {
|
|
158
|
-
return withSaturationRetry((attempt) => streamInvokeOnce(attempt, accessToken, guestSecret), options)(req);
|
|
159
|
-
}
|
|
160
42
|
/**
|
|
161
43
|
* Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
|
|
162
44
|
* access-token resolver and/or a guest-secret resolver (plus the read-plane
|
|
@@ -167,10 +49,16 @@ export function fetchStreamTransport(req, accessToken, guestSecret, options = {}
|
|
|
167
49
|
export function createWebAdapters(opts = {}) {
|
|
168
50
|
const { apiBaseUrl, getAccessToken, getGuestSecret } = opts;
|
|
169
51
|
const transport = async function* (req) {
|
|
170
|
-
const token = getAccessToken ? await getAccessToken() : null;
|
|
171
52
|
// Both candidates go to the transport; it owns the precedence (and the
|
|
172
53
|
// never-two-carriers rule) so there is exactly one place that decides.
|
|
173
|
-
|
|
54
|
+
// The bearer goes through as the PROVIDER, not a pre-resolved value:
|
|
55
|
+
// the transport re-asks it per attempt, so a cold-start retry after a
|
|
56
|
+
// backoff wait re-reads a fresh token instead of replaying one that may
|
|
57
|
+
// have expired during the wait (the same reason Portal's RN transport
|
|
58
|
+
// resolves inside its generator).
|
|
59
|
+
yield* fetchStreamTransport(req, null, getGuestSecret ? getGuestSecret() : null, {
|
|
60
|
+
getBearer: getAccessToken,
|
|
61
|
+
});
|
|
174
62
|
};
|
|
175
63
|
const adapters = {
|
|
176
64
|
storage: localStorageThreadStore,
|