@aexhq/sdk 0.55.1 → 0.57.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/session.js CHANGED
@@ -1,12 +1,38 @@
1
+ import { CustomerHand } from "@aexhq/brain";
1
2
  import * as z from "zod";
2
- import { AbortError, OutputSchemaError, OutputValidationError, SessionError, abortError, errorFromApi, } from "./errors.js";
3
- import { jcsSha256, randomIdempotencyKey } from "./json.js";
3
+ import { AbortError, OutputRefusalError, OutputSchemaError, OutputValidationError, SessionError, abortError, errorFromApi, } from "./errors.js";
4
+ import { canonicalize, jcsSha256, randomIdempotencyKey } from "./json.js";
5
+ import { compileTools } from "./tools.js";
6
+ import { SessionChildren, SessionSandbox, SessionStorage } from "./resources.js";
4
7
  export class Sessions {
5
8
  #transport;
6
- constructor(transport) {
9
+ #webSocketFactory;
10
+ #clientId;
11
+ #customerHand;
12
+ #customerHandInstance;
13
+ #closed = false;
14
+ constructor(transport, webSocketFactory, clientId) {
7
15
  this.#transport = transport;
16
+ this.#webSocketFactory = webSocketFactory;
17
+ this.#clientId = clientId;
18
+ }
19
+ /** @internal Called by `Aex.close()`. */
20
+ close() {
21
+ if (this.#closed)
22
+ return;
23
+ this.#closed = true;
24
+ this.#customerHandInstance?.close();
25
+ this.#customerHandInstance = undefined;
26
+ this.#customerHand = undefined;
8
27
  }
9
28
  async create(options, request = {}) {
29
+ if (this.#closed)
30
+ throw new SessionError("Aex client is closed");
31
+ const compiledTools = await compileTools(options.tools);
32
+ if (options.client?.submitRetries !== undefined && this.#clientId === undefined) {
33
+ throw new TypeError("client.submitRetries requires Aex({ client: { id } })");
34
+ }
35
+ await this.#ensureCustomerHand(compiledTools.clientRegistrations, request.signal);
10
36
  const body = {
11
37
  model: {
12
38
  provider: options.model.provider,
@@ -16,13 +42,52 @@ export class Sessions {
16
42
  ...(options.model.maxOutputTokens === undefined
17
43
  ? {}
18
44
  : { max_output_tokens: options.model.maxOutputTokens }),
45
+ ...(options.model.contextWindowTokens === undefined
46
+ ? {}
47
+ : { context_window_tokens: options.model.contextWindowTokens }),
19
48
  ...(options.model.temperature === undefined ? {} : { temperature: options.model.temperature }),
20
49
  ...(options.model.reasoningEffort === undefined
21
50
  ? {}
22
51
  : { reasoning_effort: options.model.reasoningEffort }),
23
52
  },
53
+ tools: {
54
+ items: compiledTools.items,
55
+ },
56
+ ...(compiledTools.bundles.length === 0 ? {} : { tool_bundles: compiledTools.bundles }),
57
+ ...(options.secrets === undefined ? {} : { secrets: options.secrets }),
24
58
  ...(options.systemPrompt === undefined ? {} : { system_prompt: options.systemPrompt }),
25
59
  ...(options.metadata === undefined ? {} : { metadata: options.metadata }),
60
+ ...(options.network === undefined
61
+ ? {}
62
+ : { network: options.network }),
63
+ ...(options.providerRecoveryRetries === undefined
64
+ ? {}
65
+ : { provider_recovery_retries: options.providerRecoveryRetries }),
66
+ ...(this.#clientId === undefined
67
+ ? {}
68
+ : {
69
+ client: {
70
+ id: this.#clientId,
71
+ ...(options.client?.submitRetries === undefined
72
+ ? {}
73
+ : { submit_retries: options.client.submitRetries }),
74
+ },
75
+ }),
76
+ ...(options.children === undefined
77
+ ? {}
78
+ : {
79
+ children: {
80
+ ...(options.children.maxDepth === undefined
81
+ ? {}
82
+ : { max_depth: options.children.maxDepth }),
83
+ ...(options.children.maxDirectChildren === undefined
84
+ ? {}
85
+ : { max_direct_children: options.children.maxDirectChildren }),
86
+ ...(options.children.maxDescendants === undefined
87
+ ? {}
88
+ : { max_descendants: options.children.maxDescendants }),
89
+ },
90
+ }),
26
91
  };
27
92
  const data = await this.#transport.json("POST", "/v1/sessions", {
28
93
  body,
@@ -54,13 +119,87 @@ export class Sessions {
54
119
  ...(list.next_cursor === undefined ? {} : { nextCursor: list.next_cursor }),
55
120
  };
56
121
  }
122
+ async #ensureCustomerHand(registrations, signal) {
123
+ if (this.#closed)
124
+ throw new SessionError("Aex client is closed");
125
+ if (registrations.length === 0)
126
+ return;
127
+ if (this.#clientId === undefined) {
128
+ throw new TypeError("Customer-app Tools require Aex({ client: { id } })");
129
+ }
130
+ if (this.#webSocketFactory === undefined) {
131
+ throw new TypeError("This runtime does not provide WebSocket; pass webSocketFactory to Aex");
132
+ }
133
+ if (this.#customerHand === undefined) {
134
+ let partial;
135
+ const starting = (async () => {
136
+ try {
137
+ partial = new CustomerHand(async () => {
138
+ const grant = await this.#transport.customerHandGrant(this.#clientId);
139
+ return {
140
+ request: { url: grant.url, protocol: grant.protocol },
141
+ observe: (observation) => this.#transport.customerHandObserve(grant.observationUrl, grant.observationToken, observation),
142
+ };
143
+ }, registrations, this.#webSocketFactory, { clientId: this.#clientId });
144
+ this.#customerHandInstance = partial;
145
+ await partial.ready;
146
+ if (this.#closed) {
147
+ partial.close();
148
+ throw new SessionError("Aex client is closed");
149
+ }
150
+ return partial;
151
+ }
152
+ catch (error) {
153
+ partial?.close();
154
+ throw error;
155
+ }
156
+ })();
157
+ this.#customerHand = starting;
158
+ void starting.catch(() => {
159
+ if (this.#customerHand === starting) {
160
+ this.#customerHand = undefined;
161
+ if (this.#customerHandInstance === partial)
162
+ this.#customerHandInstance = undefined;
163
+ }
164
+ });
165
+ // The request may stop waiting, but the process-scoped runner remains reconnectable for
166
+ // later sessions. Its grant/reconnect lifetime must never inherit one create signal.
167
+ await waitWithSignal(starting, signal);
168
+ return;
169
+ }
170
+ const hand = await waitWithSignal(this.#customerHand, signal);
171
+ if (this.#closed)
172
+ throw new SessionError("Aex client is closed");
173
+ await waitWithSignal(hand.register(registrations), signal);
174
+ }
175
+ }
176
+ function waitWithSignal(promise, signal) {
177
+ if (signal === undefined)
178
+ return promise;
179
+ if (signal.aborted)
180
+ return Promise.reject(abortError(signal.reason));
181
+ return new Promise((resolve, reject) => {
182
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
183
+ const onAbort = () => {
184
+ cleanup();
185
+ reject(abortError(signal.reason));
186
+ };
187
+ signal.addEventListener("abort", onAbort, { once: true });
188
+ promise.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
189
+ });
57
190
  }
58
191
  export class Session {
59
192
  #transport;
60
193
  #data;
194
+ sandbox;
195
+ storage;
196
+ children;
61
197
  constructor(transport, data) {
62
198
  this.#transport = transport;
63
199
  this.#data = data;
200
+ this.sandbox = new SessionSandbox(transport, data.id);
201
+ this.storage = new SessionStorage(transport, data.id);
202
+ this.children = new SessionChildren(transport, data.id);
64
203
  }
65
204
  get id() {
66
205
  return this.#data.id;
@@ -68,11 +207,24 @@ export class Session {
68
207
  get state() {
69
208
  return this.#data.state;
70
209
  }
210
+ get turnState() {
211
+ return this.#data.turn_state;
212
+ }
213
+ get parentId() {
214
+ return this.#data.parent_id;
215
+ }
216
+ get rootId() {
217
+ return this.#data.root_id;
218
+ }
219
+ get depth() {
220
+ return this.#data.depth;
221
+ }
71
222
  get model() {
72
223
  return {
73
224
  provider: this.#data.model.provider,
74
225
  name: this.#data.model.name,
75
226
  ...(this.#data.model.base_url === undefined ? {} : { baseUrl: this.#data.model.base_url }),
227
+ contextWindowTokens: this.#data.model.context_window_tokens,
76
228
  };
77
229
  }
78
230
  get createdAt() {
@@ -89,15 +241,35 @@ export class Session {
89
241
  return this;
90
242
  }
91
243
  async send(input, options = {}) {
244
+ const outputOptions = isOutputOptions(options) ? options : undefined;
245
+ const compiled = outputOptions === undefined
246
+ ? undefined
247
+ : await compileOutputSchema(outputOptions.output, outputOptions.outputRetries);
92
248
  const accepted = await this.#transport.json("POST", `/v1/sessions/${encodeURIComponent(this.id)}/messages`, {
93
249
  body: {
94
250
  content: input,
95
251
  ...(options.metadata === undefined ? {} : { metadata: options.metadata }),
252
+ ...(compiled === undefined
253
+ ? {}
254
+ : {
255
+ output: {
256
+ schema: compiled.jsonSchema,
257
+ schema_hash: compiled.schemaHash,
258
+ ...(compiled.retries === undefined ? {} : { retries: compiled.retries }),
259
+ },
260
+ }),
96
261
  },
97
262
  headers: { "Idempotency-Key": options.idempotencyKey ?? randomIdempotencyKey() },
98
263
  signal: options.signal,
99
264
  retry: true,
100
265
  });
266
+ if (compiled !== undefined) {
267
+ if (accepted.session_id !== this.id ||
268
+ accepted.output_id === undefined ||
269
+ accepted.schema_hash !== compiled.schemaHash) {
270
+ throw new SessionError("Aex returned an inconsistent typed-output admission");
271
+ }
272
+ }
101
273
  let answer = "";
102
274
  try {
103
275
  for await (const event of this.events({ after: Math.max(0, accepted.seq - 1), signal: options.signal })) {
@@ -105,79 +277,39 @@ export class Session {
105
277
  answer = event.text;
106
278
  }
107
279
  else if (event.type === "turn.failed" && event.turn_id === accepted.turn_id) {
108
- throw errorFromApi(event.error);
280
+ throw errorFromApi(event.error, undefined, outputIssues(event.error));
109
281
  }
110
282
  else if (event.type === "turn.completed" && event.turn_id === accepted.turn_id) {
111
283
  this.markIdle();
112
284
  if (event.stop_reason === "cancelled")
113
285
  throw new AbortError();
114
- return answer;
115
- }
116
- }
117
- }
118
- catch (error) {
119
- if (options.signal?.aborted === true)
120
- throw abortError(error);
121
- throw error;
122
- }
123
- throw new SessionError("The Aex event stream ended before the session finished its work");
124
- }
125
- async output(schema, input, options = {}) {
126
- let jsonSchema;
127
- try {
128
- assertPortableOutputSchema(schema);
129
- jsonSchema = z.toJSONSchema(schema, {
130
- target: "draft-2020-12",
131
- unrepresentable: "throw",
132
- });
133
- }
134
- catch (cause) {
135
- throw new OutputSchemaError(messageOf(cause, "The Zod schema cannot be represented as JSON Schema"), {
136
- cause,
137
- });
138
- }
139
- if (jsonSchema.type !== "object") {
140
- throw new OutputSchemaError("session.output() requires a Zod object schema");
141
- }
142
- const schemaHash = await jcsSha256(jsonSchema);
143
- const body = {
144
- schema: jsonSchema,
145
- schema_hash: schemaHash,
146
- ...(input === undefined ? {} : { input }),
147
- ...(options.metadata === undefined ? {} : { metadata: options.metadata }),
148
- };
149
- const accepted = await this.#transport.json("POST", `/v1/sessions/${encodeURIComponent(this.id)}/output`, {
150
- body,
151
- headers: { "Idempotency-Key": options.idempotencyKey ?? randomIdempotencyKey() },
152
- signal: options.signal,
153
- retry: true,
154
- });
155
- if (accepted.session_id !== this.id || accepted.schema_hash !== schemaHash) {
156
- throw new SessionError("Aex returned an inconsistent output admission");
157
- }
158
- try {
159
- for await (const event of this.events({ after: Math.max(0, accepted.seq - 1), signal: options.signal })) {
160
- if (event.type === "output.failed" && event.output_id === accepted.output_id) {
161
- if (event.schema_hash !== schemaHash) {
162
- throw new SessionError("Aex returned an output failure for a different schema");
163
- }
164
- this.markIdle();
165
- throw outputEventError(event.error, event.issues);
166
- }
167
- if (event.type === "output.completed" && event.output_id === accepted.output_id) {
168
- if (event.output.schema_hash !== schemaHash) {
169
- throw new SessionError("Aex returned an output value for a different schema");
170
- }
171
- this.markIdle();
172
- const parsed = await schema.safeParseAsync(event.output.value);
173
- if (!parsed.success) {
174
- throw new OutputValidationError("The output passed the wire schema but failed the original Zod schema", parsed.error.issues.map((issue) => ({
175
- path: jsonPointer(issue.path),
176
- message: issue.message,
177
- keyword: issue.code,
178
- })));
286
+ if (compiled !== undefined && outputOptions !== undefined) {
287
+ if (event.result === undefined) {
288
+ if (event.stop_reason === "refusal") {
289
+ throw new OutputRefusalError("The model refused the structured-output request");
290
+ }
291
+ throw new OutputValidationError("The model ended the turn without submitting the requested structured output", [{
292
+ path: "",
293
+ message: "The model did not call aex_submit_output",
294
+ keyword: "missing_output",
295
+ }]);
296
+ }
297
+ if (event.result.name !== "aex_submit_output" ||
298
+ event.result.metadata?.output_id !== accepted.output_id ||
299
+ event.result.metadata?.schema_hash !== compiled.schemaHash) {
300
+ throw new SessionError("Aex returned a typed result for a different request");
301
+ }
302
+ const parsed = await outputOptions.output.safeParseAsync(event.result.value);
303
+ if (!parsed.success) {
304
+ throw new OutputValidationError("The output passed the wire schema but failed the original Zod schema", parsed.error.issues.map((issue) => ({
305
+ path: jsonPointer(issue.path),
306
+ message: issue.message,
307
+ keyword: issue.code,
308
+ })));
309
+ }
310
+ return parsed.data;
179
311
  }
180
- return parsed.data;
312
+ return answer;
181
313
  }
182
314
  }
183
315
  }
@@ -188,27 +320,111 @@ export class Session {
188
320
  }
189
321
  throw error;
190
322
  }
191
- throw new SessionError("The Aex event stream ended before the typed output completed");
323
+ throw new SessionError("The Aex event stream ended before the session finished its work");
192
324
  }
325
+ /**
326
+ * Raw, attempt-aware event stream. Provisional frames have no durable cursor and may later be
327
+ * superseded; consumers rendering them must key by `attempt_id` and process
328
+ * `model.attempt_superseded`. Use `send()` when only the durable winning answer is needed.
329
+ */
193
330
  events(options = {}) {
194
331
  return this.#transport.events(this.id, options);
195
332
  }
196
333
  async cancel(options = {}) {
197
- this.#data = await this.#transport.json("POST", `/v1/sessions/${encodeURIComponent(this.id)}/cancel`, { signal: options.signal });
334
+ this.#data = await this.#transport.json("POST", `/v1/sessions/${encodeURIComponent(this.id)}/cancel`, { signal: options.signal, retry: true });
335
+ return this;
336
+ }
337
+ async end(options = {}) {
338
+ this.#data = await this.#transport.json("POST", `/v1/sessions/${encodeURIComponent(this.id)}/end`, { signal: options.signal, retry: true });
198
339
  return this;
199
340
  }
200
341
  async delete(options = {}) {
201
- await this.#transport.json("DELETE", `/v1/sessions/${encodeURIComponent(this.id)}`, {
202
- signal: options.signal,
203
- });
204
- this.#data = { ...this.#data, state: "deleted" };
342
+ await this.#transport.deleteSession(this.id, options.queue !== true, options.signal);
343
+ this.#data = {
344
+ ...this.#data,
345
+ state: options.queue === true ? "deleting" : "deleted",
346
+ };
205
347
  }
206
348
  markIdle() {
207
- this.#data = { ...this.#data, state: "idle" };
349
+ this.#data = { ...this.#data, turn_state: "idle" };
350
+ }
351
+ }
352
+ function isOutputOptions(options) {
353
+ return "output" in options;
354
+ }
355
+ async function compileOutputSchema(schema, retries) {
356
+ let jsonSchema;
357
+ try {
358
+ assertPortableOutputSchema(schema);
359
+ jsonSchema = z.toJSONSchema(schema, {
360
+ target: "draft-2020-12",
361
+ unrepresentable: "throw",
362
+ });
363
+ }
364
+ catch (cause) {
365
+ throw new OutputSchemaError(messageOf(cause, "The Zod schema cannot be represented as JSON Schema"), {
366
+ cause,
367
+ });
368
+ }
369
+ if (jsonSchema.type !== "object") {
370
+ throw new OutputSchemaError("session.send() output requires a Zod object schema");
208
371
  }
372
+ assertSupportedJsonSchema(jsonSchema);
373
+ return { jsonSchema, schemaHash: await jcsSha256(jsonSchema), retries };
374
+ }
375
+ function assertSupportedJsonSchema(schema) {
376
+ const bytes = new TextEncoder().encode(canonicalize(schema));
377
+ if (bytes.byteLength > 64 * 1024) {
378
+ throw new OutputSchemaError("The output schema exceeds the 65536-byte service limit");
379
+ }
380
+ let nodes = 0;
381
+ const visit = (value, depth) => {
382
+ if (depth > 64)
383
+ throw new OutputSchemaError("The output schema exceeds the service depth limit");
384
+ nodes += 1;
385
+ if (nodes > 4096)
386
+ throw new OutputSchemaError("The output schema exceeds the service node limit");
387
+ if (Array.isArray(value)) {
388
+ for (const child of value)
389
+ visit(child, depth + 1);
390
+ return;
391
+ }
392
+ if (value === null || typeof value !== "object")
393
+ return;
394
+ const object = value;
395
+ if ("pattern" in object || "patternProperties" in object) {
396
+ throw new OutputSchemaError("Regular-expression JSON Schema keywords are not supported for Aex structured output");
397
+ }
398
+ for (const keyword of ["$ref", "$dynamicRef", "$recursiveRef"]) {
399
+ const reference = object[keyword];
400
+ if (typeof reference === "string" && !reference.startsWith("#")) {
401
+ throw new OutputSchemaError("Remote JSON Schema references are not supported for Aex structured output");
402
+ }
403
+ }
404
+ for (const child of Object.values(object))
405
+ visit(child, depth + 1);
406
+ };
407
+ visit(schema, 0);
209
408
  }
210
- function outputEventError(error, issues) {
211
- return errorFromApi(error, undefined, issues ?? []);
409
+ function outputIssues(error) {
410
+ const details = error.details;
411
+ if (details === undefined || details === null || typeof details !== "object")
412
+ return [];
413
+ const issues = details.issues;
414
+ if (!Array.isArray(issues))
415
+ return [];
416
+ return issues.flatMap((issue) => {
417
+ if (issue === null || typeof issue !== "object")
418
+ return [];
419
+ const value = issue;
420
+ if (typeof value.path !== "string" || typeof value.message !== "string")
421
+ return [];
422
+ return [{
423
+ path: value.path,
424
+ message: value.message,
425
+ ...(typeof value.keyword === "string" ? { keyword: value.keyword } : {}),
426
+ }];
427
+ });
212
428
  }
213
429
  function jsonPointer(path) {
214
430
  if (path.length === 0)
@@ -0,0 +1,2 @@
1
+ export { compileTools } from "@aexhq/brain";
2
+ export type { ClientRegistration, CompiledTools, Tool } from "@aexhq/brain";
package/dist/tools.js ADDED
@@ -0,0 +1 @@
1
+ export { compileTools } from "@aexhq/brain";
@@ -1,11 +1,6 @@
1
- import type { Event } from "@aexhq/contracts/session";
1
+ import type { Event } from "@aexhq/brain/session";
2
+ import type { JsonRequestOptions, TransferTicket } from "@aexhq/brain";
2
3
  export type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
3
- interface JsonRequestOptions {
4
- body?: unknown;
5
- headers?: Record<string, string>;
6
- signal?: AbortSignal | undefined;
7
- retry?: boolean;
8
- }
9
4
  export interface EventOptions {
10
5
  after?: number;
11
6
  follow?: boolean;
@@ -15,8 +10,22 @@ export declare class Transport {
15
10
  #private;
16
11
  readonly baseUrl: string;
17
12
  constructor(apiKey: string, baseUrl: string, fetchImplementation: Fetch);
13
+ customerHandGrant(clientId: string, signal?: AbortSignal): Promise<{
14
+ url: string;
15
+ protocol: string;
16
+ expiresAt: string;
17
+ observationUrl: string;
18
+ observationToken: string;
19
+ }>;
20
+ customerHandObserve(url: string, token: string, observation: unknown): Promise<void>;
21
+ downloadTransfer(ticket: TransferTicket, signal?: AbortSignal, expectedBytes?: number): Promise<Uint8Array>;
22
+ downloadTransferStream(ticket: TransferTicket, signal?: AbortSignal, expectedBytes?: number): Promise<ReadableStream<Uint8Array>>;
23
+ uploadTransfer(ticket: TransferTicket, content: Uint8Array | (() => ReadableStream<Uint8Array>), bytes: number, signal?: AbortSignal): Promise<void>;
18
24
  json<T>(method: "GET" | "POST" | "DELETE", path: string, options?: JsonRequestOptions): Promise<T>;
25
+ /** Accept one durable deletion job; strict callers poll its short status resource client-side. */
26
+ deleteSession(sessionId: string, waitForCompletion: boolean, signal?: AbortSignal): Promise<void>;
19
27
  events(sessionId: string, options?: EventOptions): AsyncGenerator<Event>;
20
28
  private responseError;
21
29
  }
22
- export {};
30
+ /** @internal Incremental, constant-space decoder for Brain's bounded public SSE events. */
31
+ export declare function parseEventStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Event>;