@lazyingart/agent-web 0.1.40
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/LICENSE +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AGINTI_RPC_PATHS,
|
|
3
|
+
AgintiProtocolError,
|
|
4
|
+
FAIL_CLOSED_AGENT_CAPABILITIES,
|
|
5
|
+
failClosedCapabilities,
|
|
6
|
+
initialEventCursor,
|
|
7
|
+
rpcPathIsMutation,
|
|
8
|
+
validateAgentRequest,
|
|
9
|
+
validateAgentResponse,
|
|
10
|
+
validateIdempotencyKey,
|
|
11
|
+
validateRunId,
|
|
12
|
+
verifyAgentEvent,
|
|
13
|
+
} from "./aginti-protocol.js";
|
|
14
|
+
import {
|
|
15
|
+
addWebReleaseHeader,
|
|
16
|
+
inspectWebReleaseResponse,
|
|
17
|
+
optionalWebRelease,
|
|
18
|
+
} from "./web-release.js";
|
|
19
|
+
|
|
20
|
+
const JSON_LIMIT = 2 * 1024 * 1024;
|
|
21
|
+
const STREAM_LIMIT = 8 * 1024 * 1024;
|
|
22
|
+
const SSE_BLOCK_LIMIT = 64 * 1024;
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
24
|
+
const DEFAULT_STREAM_WALL_MS = 70_000;
|
|
25
|
+
const TERMINAL_EVENTS = new Set(["run.completed", "run.failed", "run.cancelled"]);
|
|
26
|
+
const FORBIDDEN_BROWSER_HEADERS = new Set([
|
|
27
|
+
"authorization",
|
|
28
|
+
"cookie",
|
|
29
|
+
"proxy-authorization",
|
|
30
|
+
"x-api-key",
|
|
31
|
+
"x-aginti-browser-session-id",
|
|
32
|
+
"x-aginti-principal-id",
|
|
33
|
+
"x-lazyedge-browser-session",
|
|
34
|
+
"x-lazyedge-principal-id",
|
|
35
|
+
"x-lazyedge-idempotency-key",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export class AgintiTransportError extends Error {
|
|
39
|
+
constructor(message, { code = "AGINTI_UNAVAILABLE", status = 503, retryable = true, serverRelease } = {}) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "AgintiTransportError";
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.status = status;
|
|
44
|
+
this.retryable = retryable;
|
|
45
|
+
if (serverRelease !== undefined) this.serverRelease = optionalWebRelease(serverRelease);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireFunction(value, name) {
|
|
50
|
+
if (typeof value !== "function") throw new TypeError(`${name} must be a function`);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizedBaseUrl(value) {
|
|
55
|
+
const fallback = globalThis.location?.href;
|
|
56
|
+
const base = value ?? fallback;
|
|
57
|
+
if (typeof base !== "string") throw new TypeError("baseUrl is required outside a browser");
|
|
58
|
+
const parsed = new URL(base);
|
|
59
|
+
if (!/^https?:$/u.test(parsed.protocol) || parsed.username || parsed.password) {
|
|
60
|
+
throw new TypeError("baseUrl must be an HTTP(S) URL without credentials");
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function safePrefix(value) {
|
|
66
|
+
if (typeof value !== "string" || !value.startsWith("/") || /[\\?#\u0000-\u001f\u007f]/u.test(value)
|
|
67
|
+
|| /%(?:2e|2f|5c)/iu.test(value)) {
|
|
68
|
+
throw new TypeError("transportEndpoint must be an absolute-path prefix");
|
|
69
|
+
}
|
|
70
|
+
if (value.includes("//") || value.split("/").some((part) => part === "." || part === "..")) {
|
|
71
|
+
throw new TypeError("transportEndpoint path is not normalized");
|
|
72
|
+
}
|
|
73
|
+
return value === "/" ? "" : value.replace(/\/$/u, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function endpointResolver(endpoint, baseUrl) {
|
|
77
|
+
if (typeof endpoint === "string") {
|
|
78
|
+
const prefix = safePrefix(endpoint);
|
|
79
|
+
return (nativePath) => new URL(`${prefix}${nativePath}`, baseUrl);
|
|
80
|
+
}
|
|
81
|
+
requireFunction(endpoint, "transportEndpoint");
|
|
82
|
+
return (nativePath) => {
|
|
83
|
+
const result = endpoint(nativePath);
|
|
84
|
+
if (typeof result !== "string" && !(result instanceof URL)) {
|
|
85
|
+
throw new TypeError("transportEndpoint must return a URL or URL string");
|
|
86
|
+
}
|
|
87
|
+
return new URL(result, baseUrl);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveSameOrigin(resolveEndpoint, nativePath, baseUrl) {
|
|
92
|
+
if (!Object.values(AGINTI_RPC_PATHS).includes(nativePath)) throw new TypeError("unknown AgInTi RPC path");
|
|
93
|
+
const target = resolveEndpoint(nativePath);
|
|
94
|
+
if (target.origin !== baseUrl.origin || target.username || target.password || target.search || target.hash) {
|
|
95
|
+
throw new TypeError("browser transport must resolve to an exact same-origin URL without credentials, query, or fragment");
|
|
96
|
+
}
|
|
97
|
+
if (/[\\]/u.test(target.pathname) || /%(?:2e|2f|5c)/iu.test(target.pathname)) {
|
|
98
|
+
throw new TypeError("transport URL contains an encoded or non-portable path separator");
|
|
99
|
+
}
|
|
100
|
+
if (!target.pathname.endsWith(nativePath)) throw new TypeError("transport URL must preserve the exact /agent/v1 RPC suffix");
|
|
101
|
+
const prefix = target.pathname.slice(0, -nativePath.length);
|
|
102
|
+
if (prefix && (!prefix.startsWith("/") || prefix.endsWith("/") || prefix.includes("//"))) {
|
|
103
|
+
throw new TypeError("transport URL prefix is not normalized");
|
|
104
|
+
}
|
|
105
|
+
return target.href;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function idempotencyKey(factory) {
|
|
109
|
+
const value = factory();
|
|
110
|
+
return validateIdempotencyKey(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function defaultIdempotencyKey() {
|
|
114
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
115
|
+
if (typeof globalThis.crypto?.getRandomValues !== "function") {
|
|
116
|
+
throw new TypeError("secure randomness is unavailable for mutation idempotency");
|
|
117
|
+
}
|
|
118
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(24));
|
|
119
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizedCsrf({ csrfToken, csrfHeader }) {
|
|
123
|
+
if (csrfToken === undefined) return { token: () => undefined, header: undefined };
|
|
124
|
+
const token = typeof csrfToken === "function" ? csrfToken : () => csrfToken;
|
|
125
|
+
if (typeof csrfHeader !== "string" || !/^x-[a-z0-9-]{1,62}$/u.test(csrfHeader.toLowerCase())) {
|
|
126
|
+
throw new TypeError("csrfHeader must be a bounded x-* header name");
|
|
127
|
+
}
|
|
128
|
+
if (FORBIDDEN_BROWSER_HEADERS.has(csrfHeader.toLowerCase())) throw new TypeError("csrfHeader is reserved");
|
|
129
|
+
if (csrfHeader.toLowerCase() === "x-idempotency-key") throw new TypeError("csrfHeader is reserved");
|
|
130
|
+
return { token, header: csrfHeader.toLowerCase() };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requestHeaders({ accept, csrf, mutationKey, releaseId }) {
|
|
134
|
+
const headers = addWebReleaseHeader(new Headers({
|
|
135
|
+
accept,
|
|
136
|
+
"content-type": "application/json; charset=utf-8",
|
|
137
|
+
}), releaseId);
|
|
138
|
+
const csrfValue = csrf.token();
|
|
139
|
+
if (csrfValue !== undefined) {
|
|
140
|
+
if (typeof csrfValue !== "string" || csrfValue.length < 16 || csrfValue.length > 1024 || /[\u0000-\u001f\u007f]/u.test(csrfValue)) {
|
|
141
|
+
throw new TypeError("CSRF token is invalid");
|
|
142
|
+
}
|
|
143
|
+
headers.set(csrf.header, csrfValue);
|
|
144
|
+
}
|
|
145
|
+
if (mutationKey !== undefined) headers.set("idempotency-key", mutationKey);
|
|
146
|
+
for (const name of headers.keys()) {
|
|
147
|
+
if (FORBIDDEN_BROWSER_HEADERS.has(name)) throw new TypeError(`browser request may not set ${name}`);
|
|
148
|
+
}
|
|
149
|
+
return headers;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function requirePinnedRelease(response, releaseId) {
|
|
153
|
+
const proof = inspectWebReleaseResponse(response, releaseId);
|
|
154
|
+
if (proof.kind === "unpinned" || proof.kind === "match") return;
|
|
155
|
+
if (proof.kind === "mismatch") {
|
|
156
|
+
throw new AgintiTransportError("AgInTi requires the current browser app release", {
|
|
157
|
+
code: "client_release_mismatch",
|
|
158
|
+
status: 409,
|
|
159
|
+
retryable: false,
|
|
160
|
+
serverRelease: proof.releaseId,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
throw new AgintiProtocolError("AgInTi response is missing its release identity");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function deadlineSignal(signal, timeoutMs) {
|
|
167
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal");
|
|
168
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 120_000) throw new TypeError("timeoutMs is invalid");
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
const abortFromCaller = () => controller.abort(signal.reason ?? new DOMException("request aborted", "AbortError"));
|
|
171
|
+
if (signal?.aborted) abortFromCaller();
|
|
172
|
+
else signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
173
|
+
const timer = setTimeout(() => controller.abort(new DOMException("request timed out", "TimeoutError")), timeoutMs);
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
dispose() {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
signal?.removeEventListener("abort", abortFromCaller);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mediaType(response) {
|
|
184
|
+
return String(response.headers.get("content-type") ?? "").toLowerCase().split(";", 1)[0].trim();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readBoundedText(response, maximum) {
|
|
188
|
+
const advertised = response.headers.get("content-length");
|
|
189
|
+
if (advertised !== null && (!/^\d+$/u.test(advertised) || Number(advertised) > maximum)) {
|
|
190
|
+
throw new AgintiProtocolError("response exceeded its public size bound");
|
|
191
|
+
}
|
|
192
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
193
|
+
const value = await response.text();
|
|
194
|
+
if (new TextEncoder().encode(value).byteLength > maximum) throw new AgintiProtocolError("response exceeded its public size bound");
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
const reader = response.body.getReader();
|
|
198
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
199
|
+
let size = 0;
|
|
200
|
+
let result = "";
|
|
201
|
+
try {
|
|
202
|
+
while (true) {
|
|
203
|
+
const { done, value } = await reader.read();
|
|
204
|
+
if (done) break;
|
|
205
|
+
if (!(value instanceof Uint8Array)) throw new AgintiProtocolError("response stream returned a non-byte chunk");
|
|
206
|
+
size += value.byteLength;
|
|
207
|
+
if (size > maximum) throw new AgintiProtocolError("response exceeded its public size bound");
|
|
208
|
+
result += decoder.decode(value, { stream: true });
|
|
209
|
+
}
|
|
210
|
+
result += decoder.decode();
|
|
211
|
+
return result;
|
|
212
|
+
} finally {
|
|
213
|
+
reader.releaseLock?.();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function safeUpstreamCode(value) {
|
|
218
|
+
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,79}$/u.test(value)) return "AGINTI_REQUEST_FAILED";
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function responseError(response) {
|
|
223
|
+
let code = "AGINTI_REQUEST_FAILED";
|
|
224
|
+
if (mediaType(response) === "application/json") {
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(await readBoundedText(response, 16 * 1024));
|
|
227
|
+
code = safeUpstreamCode(parsed?.error?.code);
|
|
228
|
+
} catch {
|
|
229
|
+
code = "AGINTI_REQUEST_FAILED";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const retryable = response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500;
|
|
233
|
+
return new AgintiTransportError("AgInTi request was not accepted", {
|
|
234
|
+
code,
|
|
235
|
+
status: response.status,
|
|
236
|
+
retryable,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function transportFailure(error) {
|
|
241
|
+
if (error instanceof AgintiTransportError || error instanceof AgintiProtocolError) return error;
|
|
242
|
+
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
|
|
243
|
+
return new AgintiTransportError("AgInTi request was interrupted", {
|
|
244
|
+
code: error.name === "TimeoutError" ? "AGINTI_TIMEOUT" : "AGINTI_ABORTED",
|
|
245
|
+
status: error.name === "TimeoutError" ? 504 : 499,
|
|
246
|
+
retryable: error.name === "TimeoutError",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return new AgintiTransportError("AgInTi transport is unavailable");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseSseBlock(block) {
|
|
253
|
+
if (!block) return null;
|
|
254
|
+
const lines = block.split("\n");
|
|
255
|
+
if (lines.every((line) => line === "" || line.startsWith(":"))) return null;
|
|
256
|
+
const fields = Object.create(null);
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
if (!line || line.startsWith(":")) continue;
|
|
259
|
+
const match = /^(id|event|data): ?([^\r\n]*)$/u.exec(line);
|
|
260
|
+
if (!match || Object.hasOwn(fields, match[1])) throw new AgintiProtocolError("event stream contains an unsupported or repeated SSE field");
|
|
261
|
+
fields[match[1]] = match[2];
|
|
262
|
+
}
|
|
263
|
+
if (!Object.hasOwn(fields, "id") || !Object.hasOwn(fields, "event") || !Object.hasOwn(fields, "data")) {
|
|
264
|
+
throw new AgintiProtocolError("event stream block is incomplete");
|
|
265
|
+
}
|
|
266
|
+
let value;
|
|
267
|
+
try {
|
|
268
|
+
value = JSON.parse(fields.data);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new AgintiProtocolError("event stream data is not valid JSON");
|
|
271
|
+
}
|
|
272
|
+
return { id: fields.id, type: fields.event, value };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function detachReader(reader) {
|
|
276
|
+
// A verified terminal ledger event is sufficient to release the UI. Some
|
|
277
|
+
// browser fetch implementations leave the underlying cancel promise pending
|
|
278
|
+
// after that point, so transport teardown is deliberately best effort.
|
|
279
|
+
try {
|
|
280
|
+
const cancellation = reader.cancel();
|
|
281
|
+
if (cancellation && typeof cancellation.catch === "function") {
|
|
282
|
+
void cancellation.catch(() => { /* The event delivery transport is already detaching. */ });
|
|
283
|
+
}
|
|
284
|
+
} catch { /* The event delivery transport is already detaching. */ }
|
|
285
|
+
try { reader.releaseLock?.(); } catch { /* Cancellation still owns the reader. */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function* rawSseBlocks(response) {
|
|
289
|
+
if (!response.body || typeof response.body.getReader !== "function") throw new AgintiProtocolError("event stream body is unavailable");
|
|
290
|
+
const reader = response.body.getReader();
|
|
291
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
292
|
+
let bytes = 0;
|
|
293
|
+
let buffer = "";
|
|
294
|
+
let ended = false;
|
|
295
|
+
try {
|
|
296
|
+
while (true) {
|
|
297
|
+
const { done, value } = await reader.read();
|
|
298
|
+
if (done) { ended = true; break; }
|
|
299
|
+
if (!(value instanceof Uint8Array)) throw new AgintiProtocolError("event stream returned a non-byte chunk");
|
|
300
|
+
bytes += value.byteLength;
|
|
301
|
+
if (bytes > STREAM_LIMIT) throw new AgintiProtocolError("event stream exceeded its connection bound");
|
|
302
|
+
try {
|
|
303
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/gu, "\n");
|
|
304
|
+
} catch {
|
|
305
|
+
throw new AgintiProtocolError("event stream is not valid UTF-8");
|
|
306
|
+
}
|
|
307
|
+
if (new TextEncoder().encode(buffer).byteLength > SSE_BLOCK_LIMIT && !buffer.includes("\n\n")) {
|
|
308
|
+
throw new AgintiProtocolError("event stream block exceeded its bound");
|
|
309
|
+
}
|
|
310
|
+
let separator;
|
|
311
|
+
while ((separator = buffer.indexOf("\n\n")) !== -1) {
|
|
312
|
+
const block = buffer.slice(0, separator);
|
|
313
|
+
buffer = buffer.slice(separator + 2);
|
|
314
|
+
if (new TextEncoder().encode(block).byteLength > SSE_BLOCK_LIMIT) throw new AgintiProtocolError("event stream block exceeded its bound");
|
|
315
|
+
yield block;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
try { buffer += decoder.decode(); } catch { throw new AgintiProtocolError("event stream is not valid UTF-8"); }
|
|
319
|
+
const tail = buffer.trim();
|
|
320
|
+
if (tail) {
|
|
321
|
+
if (new TextEncoder().encode(tail).byteLength > SSE_BLOCK_LIMIT) throw new AgintiProtocolError("event stream block exceeded its bound");
|
|
322
|
+
yield tail;
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
if (!ended) detachReader(reader);
|
|
326
|
+
else reader.releaseLock?.();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function* agentSseBlocks(response) {
|
|
331
|
+
for await (const block of rawSseBlocks(response)) {
|
|
332
|
+
const parsed = parseSseBlock(block);
|
|
333
|
+
if (parsed) yield parsed;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function cursor(value) {
|
|
338
|
+
if (value === undefined) return initialEventCursor();
|
|
339
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)
|
|
340
|
+
|| !Number.isSafeInteger(value.seq) || value.seq < 0 || value.seq > 10_000_000_000
|
|
341
|
+
|| typeof value.hash !== "string" || !/^[a-f0-9]{64}$/u.test(value.hash)
|
|
342
|
+
|| Object.keys(value).some((key) => !["seq", "hash"].includes(key))) {
|
|
343
|
+
throw new TypeError("event cursor is invalid");
|
|
344
|
+
}
|
|
345
|
+
if (value.seq === 0 && value.hash !== "0".repeat(64)) throw new TypeError("initial event cursor hash must be zero");
|
|
346
|
+
return Object.freeze({ seq: value.seq, hash: value.hash });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function terminal(type) {
|
|
350
|
+
return TERMINAL_EVENTS.has(type);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validateReconnects(value) {
|
|
354
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 20) throw new TypeError("maxReconnects must be an integer from 0 through 20");
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export class AgintiBrowserClient {
|
|
359
|
+
constructor({
|
|
360
|
+
transportEndpoint,
|
|
361
|
+
baseUrl,
|
|
362
|
+
fetchImpl = globalThis.fetch,
|
|
363
|
+
csrfToken,
|
|
364
|
+
csrfHeader = "x-csrf-token",
|
|
365
|
+
makeIdempotencyKey = defaultIdempotencyKey,
|
|
366
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
367
|
+
streamWallMs = DEFAULT_STREAM_WALL_MS,
|
|
368
|
+
wait = (milliseconds, signal) => new Promise((resolve, reject) => {
|
|
369
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
370
|
+
signal?.addEventListener("abort", () => {
|
|
371
|
+
clearTimeout(timer);
|
|
372
|
+
reject(signal.reason ?? new DOMException("aborted", "AbortError"));
|
|
373
|
+
}, { once: true });
|
|
374
|
+
}),
|
|
375
|
+
digest,
|
|
376
|
+
releaseId,
|
|
377
|
+
} = {}) {
|
|
378
|
+
this.baseUrl = normalizedBaseUrl(baseUrl);
|
|
379
|
+
this.resolveEndpoint = endpointResolver(transportEndpoint, this.baseUrl);
|
|
380
|
+
this.fetch = requireFunction(fetchImpl, "fetchImpl");
|
|
381
|
+
if (fetchImpl === globalThis.fetch) this.fetch = this.fetch.bind(globalThis);
|
|
382
|
+
this.csrf = normalizedCsrf({ csrfToken, csrfHeader });
|
|
383
|
+
this.releaseId = optionalWebRelease(releaseId);
|
|
384
|
+
this.makeIdempotencyKey = requireFunction(makeIdempotencyKey, "makeIdempotencyKey");
|
|
385
|
+
this.timeoutMs = timeoutMs;
|
|
386
|
+
if (!Number.isSafeInteger(streamWallMs) || streamWallMs < 1_000 || streamWallMs > 120_000) {
|
|
387
|
+
throw new TypeError("streamWallMs is invalid");
|
|
388
|
+
}
|
|
389
|
+
this.streamWallMs = streamWallMs;
|
|
390
|
+
this.wait = requireFunction(wait, "wait");
|
|
391
|
+
this.digest = digest;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
endpoint(pathname) {
|
|
395
|
+
return resolveSameOrigin(this.resolveEndpoint, pathname, this.baseUrl);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async call(pathname, body = {}, { signal, idempotency } = {}) {
|
|
399
|
+
if (pathname === AGINTI_RPC_PATHS.runsEvents) throw new TypeError("use streamRunEvents for the events RPC");
|
|
400
|
+
const request = validateAgentRequest(pathname, body);
|
|
401
|
+
const mutation = rpcPathIsMutation(pathname);
|
|
402
|
+
if (!mutation && idempotency !== undefined) throw new TypeError("read RPCs may not carry idempotency keys");
|
|
403
|
+
const mutationKey = mutation ? validateIdempotencyKey(idempotency ?? idempotencyKey(this.makeIdempotencyKey)) : undefined;
|
|
404
|
+
const endpoint = this.endpoint(pathname);
|
|
405
|
+
const deadline = deadlineSignal(signal, this.timeoutMs);
|
|
406
|
+
let response;
|
|
407
|
+
try {
|
|
408
|
+
response = await this.fetch(endpoint, {
|
|
409
|
+
method: "POST",
|
|
410
|
+
credentials: "same-origin",
|
|
411
|
+
cache: "no-store",
|
|
412
|
+
redirect: "error",
|
|
413
|
+
referrerPolicy: "same-origin",
|
|
414
|
+
headers: requestHeaders({ accept: "application/json", csrf: this.csrf, mutationKey, releaseId: this.releaseId }),
|
|
415
|
+
body: JSON.stringify(request),
|
|
416
|
+
signal: deadline.signal,
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
deadline.dispose();
|
|
420
|
+
throw transportFailure(deadline.signal.aborted ? (deadline.signal.reason ?? error) : error);
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
requirePinnedRelease(response, this.releaseId);
|
|
424
|
+
if (!response.ok) throw await responseError(response);
|
|
425
|
+
if (mediaType(response) !== "application/json") throw new AgintiProtocolError("AgInTi response content type is invalid");
|
|
426
|
+
let value;
|
|
427
|
+
try {
|
|
428
|
+
value = JSON.parse(await readBoundedText(response, JSON_LIMIT));
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (deadline.signal.aborted) throw transportFailure(deadline.signal.reason ?? error);
|
|
431
|
+
if (error instanceof AgintiProtocolError) throw error;
|
|
432
|
+
throw new AgintiProtocolError("AgInTi response is not valid JSON");
|
|
433
|
+
}
|
|
434
|
+
return validateAgentResponse(pathname, value);
|
|
435
|
+
} finally {
|
|
436
|
+
deadline.dispose();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async capabilities({ signal } = {}) {
|
|
441
|
+
try {
|
|
442
|
+
return await this.call(AGINTI_RPC_PATHS.capabilities, {}, { signal });
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error?.code === "client_release_mismatch") throw error;
|
|
445
|
+
return FAIL_CLOSED_AGENT_CAPABILITIES;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
listThreads(body = {}, options) { return this.call(AGINTI_RPC_PATHS.threadsList, body, options); }
|
|
450
|
+
createThread(body = {}, options) { return this.call(AGINTI_RPC_PATHS.threadsCreate, body, options); }
|
|
451
|
+
getThread(threadId, options) { return this.call(AGINTI_RPC_PATHS.threadsGet, { threadId }, options); }
|
|
452
|
+
updateThread(body, options) { return this.call(AGINTI_RPC_PATHS.threadsUpdate, body, options); }
|
|
453
|
+
deleteThread(threadId, options) { return this.call(AGINTI_RPC_PATHS.threadsDelete, { threadId }, options); }
|
|
454
|
+
startRun(threadId, text, { search, ...options } = {}) {
|
|
455
|
+
return this.call(AGINTI_RPC_PATHS.runsStart, {
|
|
456
|
+
threadId,
|
|
457
|
+
input: { text, ...(search === undefined ? {} : { search }) },
|
|
458
|
+
}, options);
|
|
459
|
+
}
|
|
460
|
+
runStatus(runId, options) { return this.call(AGINTI_RPC_PATHS.runsStatus, { runId }, options); }
|
|
461
|
+
cancelRun(runId, options) { return this.call(AGINTI_RPC_PATHS.runsCancel, { runId }, options); }
|
|
462
|
+
resumeRun(runId, text, { search, ...options } = {}) {
|
|
463
|
+
if (text === undefined && search !== undefined) throw new TypeError("search requires a corrected resume prompt");
|
|
464
|
+
return this.call(
|
|
465
|
+
AGINTI_RPC_PATHS.runsResume,
|
|
466
|
+
text === undefined ? { runId } : { runId, input: { text, ...(search === undefined ? {} : { search }) } },
|
|
467
|
+
options,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
listArtifacts(body, options) { return this.call(AGINTI_RPC_PATHS.artifactsList, body, options); }
|
|
471
|
+
getArtifact(artifactId, options) { return this.call(AGINTI_RPC_PATHS.artifactsGet, { artifactId }, options); }
|
|
472
|
+
|
|
473
|
+
async *streamRunEvents({
|
|
474
|
+
runId,
|
|
475
|
+
threadId,
|
|
476
|
+
cursor: suppliedCursor,
|
|
477
|
+
signal,
|
|
478
|
+
maxReconnects = 5,
|
|
479
|
+
onCursor,
|
|
480
|
+
} = {}) {
|
|
481
|
+
validateRunId(runId);
|
|
482
|
+
if (onCursor !== undefined) requireFunction(onCursor, "onCursor");
|
|
483
|
+
let delivery = cursor(suppliedCursor);
|
|
484
|
+
let reconnects = 0;
|
|
485
|
+
const maximum = validateReconnects(maxReconnects);
|
|
486
|
+
const endpoint = this.endpoint(AGINTI_RPC_PATHS.runsEvents);
|
|
487
|
+
let done = false;
|
|
488
|
+
while (!done) {
|
|
489
|
+
let response;
|
|
490
|
+
const deadline = deadlineSignal(signal, this.streamWallMs);
|
|
491
|
+
try {
|
|
492
|
+
const request = validateAgentRequest(AGINTI_RPC_PATHS.runsEvents, {
|
|
493
|
+
runId,
|
|
494
|
+
afterSeq: delivery.seq,
|
|
495
|
+
afterHash: delivery.hash,
|
|
496
|
+
});
|
|
497
|
+
response = await this.fetch(endpoint, {
|
|
498
|
+
method: "POST",
|
|
499
|
+
credentials: "same-origin",
|
|
500
|
+
cache: "no-store",
|
|
501
|
+
redirect: "error",
|
|
502
|
+
referrerPolicy: "same-origin",
|
|
503
|
+
headers: requestHeaders({ accept: "text/event-stream", csrf: this.csrf, releaseId: this.releaseId }),
|
|
504
|
+
body: JSON.stringify(request),
|
|
505
|
+
signal: deadline.signal,
|
|
506
|
+
});
|
|
507
|
+
requirePinnedRelease(response, this.releaseId);
|
|
508
|
+
if (!response.ok) throw await responseError(response);
|
|
509
|
+
if (mediaType(response) !== "text/event-stream") throw new AgintiProtocolError("AgInTi event stream content type is invalid");
|
|
510
|
+
for await (const block of agentSseBlocks(response)) {
|
|
511
|
+
const event = await verifyAgentEvent(block.value, {
|
|
512
|
+
expectedRunId: runId,
|
|
513
|
+
expectedThreadId: threadId,
|
|
514
|
+
afterSeq: delivery.seq,
|
|
515
|
+
previousHash: delivery.hash,
|
|
516
|
+
digest: this.digest,
|
|
517
|
+
});
|
|
518
|
+
if (block.id !== event.id || block.type !== event.type) throw new AgintiProtocolError("SSE fields do not match the event envelope");
|
|
519
|
+
delivery = Object.freeze({ seq: event.seq, hash: event.hash });
|
|
520
|
+
if (onCursor) {
|
|
521
|
+
try { await onCursor(delivery, event); }
|
|
522
|
+
catch { throw new AgintiProtocolError("delivery cursor persistence failed", { code: "CURSOR_PERSISTENCE_FAILED" }); }
|
|
523
|
+
}
|
|
524
|
+
yield Object.freeze({ event, cursor: delivery });
|
|
525
|
+
if (terminal(event.type)) {
|
|
526
|
+
done = true;
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (done || signal?.aborted) return;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
const failure = transportFailure(deadline.signal.aborted ? (deadline.signal.reason ?? error) : error);
|
|
533
|
+
if (failure instanceof AgintiProtocolError || !failure.retryable || signal?.aborted) throw failure;
|
|
534
|
+
} finally {
|
|
535
|
+
deadline.dispose();
|
|
536
|
+
}
|
|
537
|
+
if (reconnects >= maximum) {
|
|
538
|
+
throw new AgintiTransportError("AgInTi event stream ended before a terminal event", {
|
|
539
|
+
code: "AGINTI_STREAM_INTERRUPTED",
|
|
540
|
+
status: 503,
|
|
541
|
+
retryable: true,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
reconnects += 1;
|
|
545
|
+
await this.wait(Math.min(4_000, 250 * (2 ** (reconnects - 1))), signal);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function selectDefaultMode(capabilities) {
|
|
551
|
+
return failClosedCapabilities(capabilities).enabled ? "agent" : "chat";
|
|
552
|
+
}
|