@aexhq/sdk 0.39.0 → 0.40.1
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 +22 -16
- package/dist/_contracts/api-key.d.ts +49 -0
- package/dist/_contracts/api-key.js +87 -0
- package/dist/_contracts/bundle-manifest.d.ts +86 -0
- package/dist/_contracts/bundle-manifest.js +157 -0
- package/dist/_contracts/error-codes.d.ts +26 -0
- package/dist/_contracts/error-codes.js +79 -0
- package/dist/_contracts/error-factory.d.ts +32 -0
- package/dist/_contracts/error-factory.js +142 -0
- package/dist/_contracts/event-envelope.d.ts +33 -10
- package/dist/_contracts/event-envelope.js +46 -10
- package/dist/_contracts/event-view.d.ts +123 -0
- package/dist/_contracts/event-view.js +120 -0
- package/dist/_contracts/http.js +12 -3
- package/dist/_contracts/index.d.ts +8 -4
- package/dist/_contracts/index.js +11 -7
- package/dist/_contracts/models.d.ts +15 -0
- package/dist/_contracts/models.js +33 -0
- package/dist/_contracts/operations.d.ts +53 -2
- package/dist/_contracts/operations.js +119 -7
- package/dist/_contracts/run-record.d.ts +3 -2
- package/dist/_contracts/runtime-types.d.ts +126 -36
- package/dist/_contracts/runtime-types.js +33 -1
- package/dist/_contracts/sdk-errors.d.ts +61 -2
- package/dist/_contracts/sdk-errors.js +83 -3
- package/dist/_contracts/sdk-secrets.js +31 -6
- package/dist/_contracts/stable.d.ts +14 -0
- package/dist/_contracts/stable.js +14 -0
- package/dist/_contracts/status.d.ts +28 -1
- package/dist/_contracts/status.js +48 -3
- package/dist/_contracts/submission.d.ts +79 -2
- package/dist/_contracts/submission.js +148 -5
- package/dist/_contracts/suggest.d.ts +15 -0
- package/dist/_contracts/suggest.js +54 -0
- package/dist/asset-upload.d.ts +26 -0
- package/dist/asset-upload.js +218 -3
- package/dist/asset-upload.js.map +1 -1
- package/dist/bundle.d.ts +14 -3
- package/dist/bundle.js +34 -14
- package/dist/bundle.js.map +1 -1
- package/dist/canonical-zip.d.ts +68 -0
- package/dist/canonical-zip.js +307 -0
- package/dist/canonical-zip.js.map +1 -0
- package/dist/cli.mjs +1923 -338
- package/dist/cli.mjs.sha256 +1 -1
- package/dist/client.d.ts +217 -58
- package/dist/client.js +749 -261
- package/dist/client.js.map +1 -1
- package/dist/file.d.ts +33 -6
- package/dist/file.js +120 -54
- package/dist/file.js.map +1 -1
- package/dist/index.d.ts +17 -8
- package/dist/index.js +26 -9
- package/dist/index.js.map +1 -1
- package/dist/node-fs.d.ts +26 -9
- package/dist/node-fs.js +13 -38
- package/dist/node-fs.js.map +1 -1
- package/dist/node-walk.d.ts +69 -0
- package/dist/node-walk.js +146 -0
- package/dist/node-walk.js.map +1 -0
- package/dist/retry.d.ts +9 -13
- package/dist/retry.js +18 -17
- package/dist/retry.js.map +1 -1
- package/dist/skill.d.ts +16 -4
- package/dist/skill.js +18 -9
- package/dist/skill.js.map +1 -1
- package/dist/tool.d.ts +14 -2
- package/dist/tool.js +33 -7
- package/dist/tool.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/authentication.md +29 -5
- package/docs/billing.md +6 -0
- package/docs/concepts/agent-tools.md +11 -0
- package/docs/defaults.md +1 -0
- package/docs/errors.md +64 -4
- package/docs/events.md +84 -49
- package/docs/limits-and-quotas.md +24 -0
- package/docs/networking.md +7 -1
- package/docs/outputs.md +36 -7
- package/docs/quickstart.md +17 -7
- package/docs/run-config.md +3 -3
- package/docs/secrets.md +14 -0
- package/examples/feature-tour.ts +4 -6
- package/examples/spike-settle-latency.ts +125 -0
- package/package.json +4 -3
- package/dist/_contracts/event-guards.d.ts +0 -67
- package/dist/_contracts/event-guards.js +0 -36
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AexApiError,
|
|
1
|
+
import { AexApiError, CredentialValidationError, HttpClient, PLANE_BASE_URLS, RunConfigValidationError, RunStateError, SecretString, asAexEventView, customName, isRunSettled, assertStreamableOutputMode, operations, parseApiKey, resolveModelProvider, streamCoordinatorEvents, usageFromProviderUsage, parseRunLimits, parseRuntimeSize, parseRunTimeout, parseRunWebhook, BUILTIN_TOOL_NAMES, SKILLS_MAX, TERMINAL_RUN_STATUSES } from "./_contracts/index.js";
|
|
2
2
|
import { AgentsMd } from "./agents-md.js";
|
|
3
|
-
import { uploadAsset } from "./asset-upload.js";
|
|
3
|
+
import { uploadAsset, uploadAssetMultipart } from "./asset-upload.js";
|
|
4
4
|
import { File } from "./file.js";
|
|
5
5
|
import { McpServer } from "./mcp-server.js";
|
|
6
6
|
import { AexRateLimitError, isThrottleFault, parseProviderFault, withRetry } from "./retry.js";
|
|
@@ -122,9 +122,9 @@ export class SessionHandle {
|
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
124
|
async *#send(input, options) {
|
|
125
|
-
const idempotencyKey = options.idempotencyKey
|
|
125
|
+
const idempotencyKey = operations.resolveIdempotencyKey(options.idempotencyKey);
|
|
126
126
|
this.#lastSend = { input, idempotencyKey };
|
|
127
|
-
const accepted = await
|
|
127
|
+
const accepted = await this.#acceptTurn(input, idempotencyKey, options.signal);
|
|
128
128
|
this.#session = accepted.session;
|
|
129
129
|
const turn = accepted.turn;
|
|
130
130
|
const events = [];
|
|
@@ -135,21 +135,70 @@ export class SessionHandle {
|
|
|
135
135
|
events.push(event);
|
|
136
136
|
yield event;
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
// Read the CARRIED terminal outcome from the events (never re-derive a lossy idle).
|
|
139
|
+
const read = terminalSessionStatusFromEvents(events, turn.turnSeq);
|
|
140
|
+
// Await the settle commit by DEFAULT so cost/usage + the terminal outcome are
|
|
141
|
+
// always present; `await: 'park'` returns at the render-complete park event.
|
|
139
142
|
const readSession = await operations.getSession(this.#http, this.id).catch(() => this.#session);
|
|
140
|
-
|
|
143
|
+
const settled = (options.await ?? "settle") === "park"
|
|
144
|
+
? readSession
|
|
145
|
+
: (await settledSessionRecord(this.#http, this.id, readSession, options.signal)) ?? readSession;
|
|
146
|
+
this.#session = withTerminalSessionStatus(settled, read);
|
|
141
147
|
const outputs = await operations.listSessionOutputs(this.#http, this.id).catch(() => []);
|
|
142
148
|
const messages = projectAssistantMessages(events);
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
return settledTurnResult(this.id, this.#session, turn, events, outputs, messages, read);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* POST the next turn, reconciling the settle-lag race. A turn stream ends on
|
|
153
|
+
* the idle park EVENT, but the session RECORD can lag at `running` for a short
|
|
154
|
+
* window before the platform commits it — so an immediate follow-up `send()`
|
|
155
|
+
* 409s `session_busy` even though, from the caller's view, the previous turn
|
|
156
|
+
* already parked. When that happens we poll the record until it leaves
|
|
157
|
+
* `running`, then retry the SAME idempotent POST (a replay de-duplicates, so
|
|
158
|
+
* this never creates a second billable turn). A session that stays busy past
|
|
159
|
+
* {@link SESSION_BUSY_RECONCILE_DEADLINE_MS} is a genuinely in-flight turn: we
|
|
160
|
+
* surface a clear, bounded {@link RunStateError} rather than masking it or
|
|
161
|
+
* waiting forever. Any non-`running` busy status (suspended / cancelling /
|
|
162
|
+
* deleted) is a real rejection and passes straight through.
|
|
163
|
+
*/
|
|
164
|
+
async #acceptTurn(input, idempotencyKey, signal) {
|
|
165
|
+
const deadline = Date.now() + SESSION_BUSY_RECONCILE_DEADLINE_MS;
|
|
166
|
+
for (;;) {
|
|
167
|
+
try {
|
|
168
|
+
return await operations.sendSessionMessage(this.#http, this.id, { input }, { idempotencyKey });
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (!isSettlingSessionBusy(err) || signal?.aborted)
|
|
172
|
+
throw err;
|
|
173
|
+
if (Date.now() >= deadline) {
|
|
174
|
+
throw new RunStateError(`SessionHandle.send: session ${this.id} was still running ${SESSION_BUSY_RECONCILE_DEADLINE_MS}ms after the previous turn parked — a turn is still in flight`, { sessionId: this.id, status: "running", cause: err });
|
|
175
|
+
}
|
|
176
|
+
await this.#awaitLeftRunning(deadline, signal);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Poll the session record until it is no longer `running`/`creating`, or the
|
|
182
|
+
* reconcile deadline elapses. Updates the stored record so the eventual retry
|
|
183
|
+
* (or the terminal error) reflects the freshest status.
|
|
184
|
+
*/
|
|
185
|
+
async #awaitLeftRunning(deadline, signal) {
|
|
186
|
+
for (;;) {
|
|
187
|
+
try {
|
|
188
|
+
await sleep(SESSION_BUSY_RECONCILE_INTERVAL_MS, signal);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return; // aborted — let the retry POST surface the real state
|
|
192
|
+
}
|
|
193
|
+
const record = await operations.getSession(this.#http, this.id).catch(() => undefined);
|
|
194
|
+
if (record !== undefined) {
|
|
195
|
+
this.#session = record;
|
|
196
|
+
if (record.status !== "running" && record.status !== "creating")
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (Date.now() >= deadline)
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
153
202
|
}
|
|
154
203
|
async suspend(options = {}) {
|
|
155
204
|
const accepted = await operations.suspendSession(this.#http, this.id, options);
|
|
@@ -172,6 +221,39 @@ export class SessionHandle {
|
|
|
172
221
|
this.#session = accepted.session;
|
|
173
222
|
}
|
|
174
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Request the HITL write-gate: park this session `awaiting_approval` before
|
|
226
|
+
* its next gated action. Imperative counterpart to the declarative
|
|
227
|
+
* `approvalGate` submission option. Resume with {@link approve} / reject with
|
|
228
|
+
* {@link deny}.
|
|
229
|
+
*/
|
|
230
|
+
async requestApproval(options = {}) {
|
|
231
|
+
const accepted = await operations.requestApproval(this.#http, this.id, options);
|
|
232
|
+
this.#session = accepted.session;
|
|
233
|
+
return accepted;
|
|
234
|
+
}
|
|
235
|
+
/** Approve an `awaiting_approval` session so the held turn resumes (→ running). */
|
|
236
|
+
async approve(options = {}) {
|
|
237
|
+
const accepted = await operations.approveSession(this.#http, this.id, options);
|
|
238
|
+
this.#session = accepted.session;
|
|
239
|
+
return accepted;
|
|
240
|
+
}
|
|
241
|
+
/** Deny an `awaiting_approval` session so the held turn is cancelled (→ cancelled). */
|
|
242
|
+
async deny(options = {}) {
|
|
243
|
+
const accepted = await operations.denySession(this.#http, this.id, options);
|
|
244
|
+
this.#session = accepted.session;
|
|
245
|
+
return accepted;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Enumerate this run's subagent CHILD runs (`GET /runs/:id/children`). Each is
|
|
249
|
+
* a {@link ChildRunHandle} backed by the RUN facade (getRun/events/outputs) —
|
|
250
|
+
* NOT `openSession` — so every child the platform hands you is resolvable, with
|
|
251
|
+
* its lineage (`parentRunId`/`depth`) and terminal outcome exposed.
|
|
252
|
+
*/
|
|
253
|
+
async children() {
|
|
254
|
+
const refs = await operations.listRunChildren(this.#http, this.id);
|
|
255
|
+
return refs.map((ref) => new ChildRunHandle(this.#http, ref, this.#fetch));
|
|
256
|
+
}
|
|
175
257
|
/**
|
|
176
258
|
* Accessor for the session's assistant messages. `all()` returns them
|
|
177
259
|
* oldest-first; `last()`/`first()` return a single entry or `undefined` when
|
|
@@ -209,14 +291,14 @@ export class SessionHandle {
|
|
|
209
291
|
}
|
|
210
292
|
/**
|
|
211
293
|
* Accessor for the session's event stream: the buffered `SessionEvent`
|
|
212
|
-
* snapshots (`list`/`last`/`first`), the polling `
|
|
294
|
+
* snapshots (`list`/`last`/`first`), the polling `AexEventView` iterator
|
|
213
295
|
* (`stream`), the live coordinator envelope iterator (`streamEnvelopes`), and
|
|
214
296
|
* the events-namespace archive (`archiveLink`/`download`).
|
|
215
297
|
*/
|
|
216
298
|
events() {
|
|
217
299
|
const http = this.#http;
|
|
218
300
|
const id = this.id;
|
|
219
|
-
const list = () => operations.listSessionEvents(http, id);
|
|
301
|
+
const list = async () => (await operations.listSessionEvents(http, id)).map(asAexEventView);
|
|
220
302
|
return {
|
|
221
303
|
list,
|
|
222
304
|
last: async () => (await list()).at(-1),
|
|
@@ -315,7 +397,7 @@ export class SessionClient {
|
|
|
315
397
|
}
|
|
316
398
|
async create(options) {
|
|
317
399
|
const request = await this.#buildCreateRequest(options);
|
|
318
|
-
const session = await operations.createSession(this.#http, request, { idempotencyKey: options.idempotencyKey
|
|
400
|
+
const session = await operations.createSession(this.#http, request, { idempotencyKey: operations.resolveIdempotencyKey(options.idempotencyKey) });
|
|
319
401
|
return new SessionHandle(this.#http, session, this.#fetch);
|
|
320
402
|
}
|
|
321
403
|
async open(sessionId) {
|
|
@@ -340,65 +422,73 @@ export class SessionClient {
|
|
|
340
422
|
outputs(sessionId) {
|
|
341
423
|
return sessionOutputs(this.#http, sessionId, this.#fetch);
|
|
342
424
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
425
|
+
async run(options) {
|
|
426
|
+
const { message, deleteAfter, messageIdempotencyKey, stream, ...createOptions } = options;
|
|
427
|
+
assertNoLegacySessionFields(options, "Aex.sessions.run");
|
|
428
|
+
const input = normaliseSessionInput(message, "Aex.sessions.run", "message");
|
|
429
|
+
// Derive the message key from the create key (like the CLI) so a retried run
|
|
430
|
+
// with the same `idempotencyKey` de-duplicates BOTH the create and the
|
|
431
|
+
// billable turn — never a duplicate billable run.
|
|
432
|
+
const createKey = operations.resolveIdempotencyKey(createOptions.idempotencyKey);
|
|
433
|
+
const messageKey = messageIdempotencyKey !== undefined ? operations.resolveIdempotencyKey(messageIdempotencyKey) : deriveMessageKey(createKey);
|
|
434
|
+
const session = await this.create({ ...createOptions, idempotencyKey: createKey });
|
|
435
|
+
const result = await session.send(input, {
|
|
436
|
+
...(stream ?? {}),
|
|
437
|
+
idempotencyKey: messageKey
|
|
438
|
+
}).done();
|
|
439
|
+
if (deleteAfter) {
|
|
440
|
+
await session.delete();
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Cross-run output search (`aex.outputs`). Composed client-side (per-run
|
|
447
|
+
* `listSessionOutputs` + the contracts output filter): scope a corpus with
|
|
448
|
+
* `query.runIds`, or omit it to scan every run in the workspace. Metadata-only
|
|
449
|
+
* (reference hits, no bytes); a content-shaped query throws a typed
|
|
450
|
+
* "content search unsupported".
|
|
451
|
+
*/
|
|
452
|
+
export class OutputsClient {
|
|
453
|
+
#http;
|
|
454
|
+
constructor(http) {
|
|
455
|
+
this.#http = http;
|
|
456
|
+
}
|
|
457
|
+
async search(query = {}) {
|
|
458
|
+
assertMetadataOnlyOutputSearch(query, "aex.outputs.search");
|
|
459
|
+
// Dedup the caller-supplied allow-list so a run repeated in `runIds` (from
|
|
460
|
+
// concatenating corpora) isn't scanned twice and doesn't inflate hit count.
|
|
355
461
|
const unscoped = query.runIds === undefined;
|
|
356
|
-
const
|
|
462
|
+
const runIds = unscoped ? await this.#allRunIds() : [...new Set(query.runIds)];
|
|
357
463
|
const limit = query.limit ?? 100;
|
|
358
|
-
// Translate the search query to an OutputQuery so the contracts output
|
|
359
|
-
// filter does the matching — no re-derived filter logic here.
|
|
360
|
-
const outputQuery = {
|
|
361
|
-
...(query.filename ? { filename: new RegExp(escapeRegExp(query.filename), "i") } : {}),
|
|
362
|
-
...(query.extension ? { extension: query.extension } : {}),
|
|
363
|
-
...(query.contentType ? { contentType: query.contentType } : {})
|
|
364
|
-
};
|
|
365
|
-
const hasFilter = Object.keys(outputQuery).length > 0;
|
|
366
464
|
const hits = [];
|
|
367
|
-
for (const
|
|
465
|
+
for (const runId of runIds) {
|
|
368
466
|
let outputs;
|
|
369
467
|
try {
|
|
370
|
-
outputs =
|
|
371
|
-
? await operations.listSessionOutputs(this.#http, sessionId, outputQuery)
|
|
372
|
-
: await operations.listSessionOutputs(this.#http, sessionId);
|
|
468
|
+
outputs = await searchRunOutputs(this.#http, runId, query);
|
|
373
469
|
}
|
|
374
470
|
catch (err) {
|
|
375
471
|
if (unscoped && isMissingOutputsSession(err))
|
|
376
472
|
continue;
|
|
377
473
|
throw err;
|
|
378
474
|
}
|
|
379
|
-
for (const
|
|
380
|
-
hits.push(
|
|
381
|
-
runId: sessionId,
|
|
382
|
-
outputId: o.id,
|
|
383
|
-
...(o.filename !== undefined ? { filename: o.filename } : {}),
|
|
384
|
-
...(o.sizeBytes !== undefined ? { sizeBytes: o.sizeBytes } : {}),
|
|
385
|
-
...(o.contentType !== undefined ? { contentType: o.contentType } : {})
|
|
386
|
-
});
|
|
475
|
+
for (const hit of outputHits(runId, outputs, limit - hits.length)) {
|
|
476
|
+
hits.push(hit);
|
|
387
477
|
if (hits.length >= limit)
|
|
388
478
|
return { hits };
|
|
389
479
|
}
|
|
390
480
|
}
|
|
391
481
|
return { hits };
|
|
392
482
|
}
|
|
393
|
-
/** Enumerate every
|
|
394
|
-
async #
|
|
483
|
+
/** Enumerate every run id in the workspace by paging `listSessions`. */
|
|
484
|
+
async #allRunIds() {
|
|
395
485
|
const ids = [];
|
|
396
486
|
const seenCursors = new Set();
|
|
397
487
|
let cursor;
|
|
398
488
|
do {
|
|
399
489
|
if (cursor !== undefined) {
|
|
400
490
|
if (seenCursors.has(cursor)) {
|
|
401
|
-
throw new Error("
|
|
491
|
+
throw new Error("aex.outputs.search: listSessions returned a repeated cursor");
|
|
402
492
|
}
|
|
403
493
|
seenCursors.add(cursor);
|
|
404
494
|
}
|
|
@@ -409,29 +499,152 @@ export class SessionClient {
|
|
|
409
499
|
} while (cursor);
|
|
410
500
|
return ids;
|
|
411
501
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* A first-class, lineage-discoverable SUBAGENT CHILD run — handed out by
|
|
505
|
+
* `session.children()` / `run.children()`, backed by the RUN facade
|
|
506
|
+
* (getRun/events/outputs), NOT `openSession`. Every child the platform hands you
|
|
507
|
+
* is resolvable through this handle; its lineage (`parentRunId`/`depth`) and
|
|
508
|
+
* terminal outcome (`status`) are first-class.
|
|
509
|
+
*/
|
|
510
|
+
export class ChildRunHandle {
|
|
511
|
+
#http;
|
|
512
|
+
#fetch;
|
|
513
|
+
#ref;
|
|
514
|
+
constructor(http, ref, fetch) {
|
|
515
|
+
this.#http = http;
|
|
516
|
+
this.#ref = ref;
|
|
517
|
+
this.#fetch = fetch;
|
|
518
|
+
}
|
|
519
|
+
get id() {
|
|
520
|
+
return this.#ref.id;
|
|
521
|
+
}
|
|
522
|
+
get parentRunId() {
|
|
523
|
+
return this.#ref.parentRunId;
|
|
524
|
+
}
|
|
525
|
+
get depth() {
|
|
526
|
+
return this.#ref.depth;
|
|
527
|
+
}
|
|
528
|
+
/** The child's run status (a real run terminal outcome once settled). */
|
|
529
|
+
get status() {
|
|
530
|
+
return this.#ref.status;
|
|
531
|
+
}
|
|
532
|
+
get ref() {
|
|
533
|
+
return this.#ref;
|
|
534
|
+
}
|
|
535
|
+
/** Re-read the child run record (status, lineage, costTelemetry). */
|
|
536
|
+
get() {
|
|
537
|
+
return operations.getRun(this.#http, this.id);
|
|
538
|
+
}
|
|
539
|
+
/** The child's events over the RUN facade (`/runs/:id/events`). */
|
|
540
|
+
events() {
|
|
541
|
+
return runEventsAccessor(this.#http, this.id);
|
|
542
|
+
}
|
|
543
|
+
/** The child's captured outputs over the RUN facade (`/runs/:id/outputs`). */
|
|
544
|
+
outputs() {
|
|
545
|
+
return runOutputsAccessor(this.#http, this.id, this.#fetch);
|
|
546
|
+
}
|
|
547
|
+
/** This child's own subagent children (recursive lineage). */
|
|
548
|
+
async children() {
|
|
549
|
+
const refs = await operations.listRunChildren(this.#http, this.id);
|
|
550
|
+
return refs.map((ref) => new ChildRunHandle(this.#http, ref, this.#fetch));
|
|
551
|
+
}
|
|
552
|
+
/** Cancel the child run (run facade `POST /runs/:id/cancel`). */
|
|
553
|
+
cancel() {
|
|
554
|
+
return operations.cancelRun(this.#http, this.id);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** Run-facade events accessor (list + polling stream) — used by {@link ChildRunHandle}. */
|
|
558
|
+
function runEventsAccessor(http, id) {
|
|
559
|
+
return {
|
|
560
|
+
list: async () => (await operations.listRunEvents(http, id)).map(asAexEventView),
|
|
561
|
+
stream: (options) => streamRunEventsPolling(http, id, options ?? {})
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/** Run-facade outputs accessor — used by {@link ChildRunHandle}. */
|
|
565
|
+
function runOutputsAccessor(http, id, fetchLike) {
|
|
566
|
+
void fetchLike;
|
|
567
|
+
return {
|
|
568
|
+
list: (query) => operations.listOutputs(http, id, query),
|
|
569
|
+
find: (query) => operations.findOutputs(http, id, query),
|
|
570
|
+
findOne: (query) => operations.findOutput(http, id, query),
|
|
571
|
+
read: (selector, options) => operations.readOutputText(http, id, selector, options),
|
|
572
|
+
link: (selectorOrQuery, options) => operations.outputLink(http, id, selectorOrQuery, options),
|
|
573
|
+
download: (selector, options) => downloadSessionOutput(http, id, selector, options)
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Poll a RUN's events (via the run facade) until it reaches a terminal status,
|
|
578
|
+
* the signal aborts, or the caller breaks the iterator. Uses `getRun` (not
|
|
579
|
+
* `getSession`) so it resolves for a child run that has no session facade.
|
|
580
|
+
*/
|
|
581
|
+
async function* streamRunEventsPolling(http, id, options) {
|
|
582
|
+
if (options.signal?.aborted)
|
|
583
|
+
return;
|
|
584
|
+
const seenIds = new Set();
|
|
585
|
+
const intervalMs = options.intervalMs ?? 1_000;
|
|
586
|
+
const signal = options.signal;
|
|
587
|
+
while (!signal?.aborted) {
|
|
588
|
+
const events = await operations.listRunEvents(http, id);
|
|
589
|
+
for (const event of events) {
|
|
590
|
+
if (!seenIds.has(event.id)) {
|
|
591
|
+
seenIds.add(event.id);
|
|
592
|
+
yield asAexEventView(event);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const run = await operations.getRun(http, id);
|
|
596
|
+
if (isSessionParked(run.status))
|
|
597
|
+
return;
|
|
598
|
+
try {
|
|
599
|
+
await sleep(intervalMs, signal);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return;
|
|
428
603
|
}
|
|
429
|
-
return result;
|
|
430
604
|
}
|
|
431
605
|
}
|
|
606
|
+
/** Default / max concurrent items for {@link Aex.batch} (bounded below the tier cap). */
|
|
607
|
+
const DEFAULT_BATCH_CONCURRENCY = 4;
|
|
608
|
+
const BATCH_MAX_CONCURRENCY = 10;
|
|
609
|
+
/** Sum the per-item settled cost/usage into a real {@link BatchResult} rollup. */
|
|
610
|
+
function rollupBatch(results) {
|
|
611
|
+
let totalCostUsd = 0;
|
|
612
|
+
let okCount = 0;
|
|
613
|
+
const failed = [];
|
|
614
|
+
for (const result of results) {
|
|
615
|
+
totalCostUsd += result.costUsd;
|
|
616
|
+
if (result.ok)
|
|
617
|
+
okCount += 1;
|
|
618
|
+
else
|
|
619
|
+
failed.push(result);
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
results,
|
|
623
|
+
totalCostUsd,
|
|
624
|
+
totalUsage: sumUsageSummaries(results.map((result) => result.usage)),
|
|
625
|
+
okCount,
|
|
626
|
+
failed
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/** Field-wise sum of {@link UsageSummary} objects; a field is present iff some item carried it. */
|
|
630
|
+
function sumUsageSummaries(usages) {
|
|
631
|
+
const keys = ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "totalTokens"];
|
|
632
|
+
const out = {};
|
|
633
|
+
for (const key of keys) {
|
|
634
|
+
let sum;
|
|
635
|
+
for (const usage of usages) {
|
|
636
|
+
const value = usage[key];
|
|
637
|
+
if (typeof value === "number")
|
|
638
|
+
sum = (sum ?? 0) + value;
|
|
639
|
+
}
|
|
640
|
+
if (sum !== undefined)
|
|
641
|
+
out[key] = sum;
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
432
645
|
async function* streamSessionTurnEvents(http, sessionId, turn, options) {
|
|
433
646
|
const first = await operations.getSessionCoordinatorTicket(http, sessionId);
|
|
434
|
-
|
|
647
|
+
for await (const event of streamCoordinatorEvents({
|
|
435
648
|
wsUrl: first.wsUrl,
|
|
436
649
|
from: options.from ?? 0,
|
|
437
650
|
fetchTicket: async () => (await operations.getSessionCoordinatorTicket(http, sessionId)).ticket,
|
|
@@ -440,12 +653,16 @@ async function* streamSessionTurnEvents(http, sessionId, turn, options) {
|
|
|
440
653
|
...(options.webSocketFactory ? { webSocketFactory: options.webSocketFactory } : {}),
|
|
441
654
|
...(options.idleTimeoutMs !== undefined ? { idleTimeoutMs: options.idleTimeoutMs } : {}),
|
|
442
655
|
...(options.pingIntervalMs !== undefined ? { pingIntervalMs: options.pingIntervalMs } : {})
|
|
443
|
-
})
|
|
656
|
+
})) {
|
|
657
|
+
yield asAexEventView(event);
|
|
658
|
+
}
|
|
444
659
|
}
|
|
445
660
|
/**
|
|
446
|
-
* Poll the session's
|
|
447
|
-
* aborts, or the caller breaks the iterator, deduping by event id.
|
|
448
|
-
*
|
|
661
|
+
* Poll the session's event snapshots until the session parks, the signal
|
|
662
|
+
* aborts, or the caller breaks the iterator, deduping by event id. Yields the
|
|
663
|
+
* one canonical guard-bearing {@link AexEventView} (same shape as every other
|
|
664
|
+
* event surface). Module-level so `SessionHandle.events()` can hand it to its
|
|
665
|
+
* accessor object literal.
|
|
449
666
|
*/
|
|
450
667
|
async function* streamSessionEventsPolling(http, id, options) {
|
|
451
668
|
if (options.signal?.aborted)
|
|
@@ -458,7 +675,7 @@ async function* streamSessionEventsPolling(http, id, options) {
|
|
|
458
675
|
for (const event of events) {
|
|
459
676
|
if (!seenIds.has(event.id)) {
|
|
460
677
|
seenIds.add(event.id);
|
|
461
|
-
yield event;
|
|
678
|
+
yield asAexEventView(event);
|
|
462
679
|
}
|
|
463
680
|
}
|
|
464
681
|
const session = await operations.getSession(http, id);
|
|
@@ -481,7 +698,7 @@ async function* streamSessionEventsPolling(http, id, options) {
|
|
|
481
698
|
*/
|
|
482
699
|
async function* streamSessionEnvelopes(http, id, options) {
|
|
483
700
|
const first = await operations.getSessionCoordinatorTicket(http, id);
|
|
484
|
-
|
|
701
|
+
for await (const event of streamCoordinatorEvents({
|
|
485
702
|
wsUrl: first.wsUrl,
|
|
486
703
|
from: options.from ?? 0,
|
|
487
704
|
fetchTicket: async () => (await operations.getSessionCoordinatorTicket(http, id)).ticket,
|
|
@@ -489,7 +706,9 @@ async function* streamSessionEnvelopes(http, id, options) {
|
|
|
489
706
|
// the earlier RUN_FINISHED UX signal.
|
|
490
707
|
...(options.settleConsistent ? { isTerminal: isRunSettled } : {}),
|
|
491
708
|
...(options.signal ? { signal: options.signal } : {})
|
|
492
|
-
})
|
|
709
|
+
})) {
|
|
710
|
+
yield asAexEventView(event);
|
|
711
|
+
}
|
|
493
712
|
}
|
|
494
713
|
/**
|
|
495
714
|
* Download captured deliverables. No selector → the full outputs namespace as a
|
|
@@ -497,17 +716,11 @@ async function* streamSessionEnvelopes(http, id, options) {
|
|
|
497
716
|
* `SessionHandle.outputs()` can hand it to its accessor object literal.
|
|
498
717
|
*/
|
|
499
718
|
async function downloadSessionOutput(http, id, selector, options) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const output = isOutputPathSelector(selector)
|
|
506
|
-
? resolveOutputFileSelector(await operations.listOutputs(http, id), selector, id)
|
|
507
|
-
: resolveOutputFileSelector([], selector, id);
|
|
508
|
-
const { response } = await http.download(`/api/runs/${encodeURIComponent(id)}/outputs/${encodeURIComponent(output.id)}/download`);
|
|
509
|
-
bytes = new Uint8Array(await response.arrayBuffer());
|
|
510
|
-
}
|
|
719
|
+
// One selector-resolution path: the contracts `downloadOutput` lists-if-path
|
|
720
|
+
// then downloads, throwing with PUBLIC verb names — no duplicated resolver.
|
|
721
|
+
const bytes = selector === undefined
|
|
722
|
+
? await operations.downloadOutputs(http, id)
|
|
723
|
+
: (await operations.downloadOutput(http, id, selector)).bytes;
|
|
511
724
|
return writeOptionalFile(bytes, options?.to);
|
|
512
725
|
}
|
|
513
726
|
/**
|
|
@@ -526,6 +739,11 @@ function sessionOutputs(http, id, fetchLike) {
|
|
|
526
739
|
read: (selector, options) => operations.readOutputText(http, id, selector, options),
|
|
527
740
|
find: (query) => operations.findOutputs(http, id, query),
|
|
528
741
|
findOne: (query) => operations.findOutput(http, id, query),
|
|
742
|
+
search: async (query = {}) => {
|
|
743
|
+
assertMetadataOnlyOutputSearch(query, "outputs().search");
|
|
744
|
+
const outputs = await searchRunOutputs(http, id, query);
|
|
745
|
+
return { hits: outputHits(id, outputs, query.limit ?? 100) };
|
|
746
|
+
},
|
|
529
747
|
link: (selectorOrQuery, options) => operations.outputLink(http, id, selectorOrQuery, options),
|
|
530
748
|
fetch: async (selectorOrQuery, options) => {
|
|
531
749
|
const link = await operations.outputLink(http, id, selectorOrQuery, options);
|
|
@@ -534,6 +752,51 @@ function sessionOutputs(http, id, fetchLike) {
|
|
|
534
752
|
download: (selector, options) => downloadSessionOutput(http, id, selector, options)
|
|
535
753
|
};
|
|
536
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* List one run's outputs matching a metadata search: `extension`/`contentType`
|
|
757
|
+
* via the contracts filter, and `filename` via {@link operations.toFilenameMatcher}
|
|
758
|
+
* — a case-insensitive SUBSTRING for a string, `.test` for a RegExp (RegExp-safe;
|
|
759
|
+
* no `escapeRegExp` footgun on a reused pattern).
|
|
760
|
+
*/
|
|
761
|
+
async function searchRunOutputs(http, runId, query) {
|
|
762
|
+
const listQuery = {
|
|
763
|
+
...(query.extension !== undefined ? { extension: query.extension } : {}),
|
|
764
|
+
...(query.contentType !== undefined ? { contentType: query.contentType } : {})
|
|
765
|
+
};
|
|
766
|
+
const outputs = await operations.listSessionOutputs(http, runId, Object.keys(listQuery).length > 0 ? listQuery : undefined);
|
|
767
|
+
if (query.filename === undefined)
|
|
768
|
+
return outputs;
|
|
769
|
+
const match = operations.toFilenameMatcher(query.filename);
|
|
770
|
+
return outputs.filter((output) => typeof output.filename === "string" && match(output.filename));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Fail-fast on a CONTENT-shaped search: the search surface is metadata-only, so
|
|
774
|
+
* a `content`/`text`/`query` needle throws a typed error rather than silently
|
|
775
|
+
* returning 0 hits (which reads as "no matches" for a query that was never run).
|
|
776
|
+
*/
|
|
777
|
+
function assertMetadataOnlyOutputSearch(query, surface) {
|
|
778
|
+
for (const key of ["content", "text", "query", "grep", "body"]) {
|
|
779
|
+
if (Object.prototype.hasOwnProperty.call(query, key)) {
|
|
780
|
+
throw new RunConfigValidationError(`${surface}: content search is not supported — search matches on filename/extension/contentType metadata only`, { field: key });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/** Project a run's output files to reference-only {@link OutputSearchHit}s, capped. */
|
|
785
|
+
function outputHits(runId, outputs, limit) {
|
|
786
|
+
const hits = [];
|
|
787
|
+
for (const o of outputs) {
|
|
788
|
+
hits.push({
|
|
789
|
+
runId,
|
|
790
|
+
outputId: o.id,
|
|
791
|
+
...(o.filename !== undefined ? { filename: o.filename } : {}),
|
|
792
|
+
...(o.sizeBytes !== undefined ? { sizeBytes: o.sizeBytes } : {}),
|
|
793
|
+
...(o.contentType !== undefined ? { contentType: o.contentType } : {})
|
|
794
|
+
});
|
|
795
|
+
if (hits.length >= limit)
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
return hits;
|
|
799
|
+
}
|
|
537
800
|
function messageFromWire(message) {
|
|
538
801
|
return {
|
|
539
802
|
id: message.id,
|
|
@@ -603,8 +866,7 @@ function runTraceFromEvents(events) {
|
|
|
603
866
|
}
|
|
604
867
|
function assistantTextEntriesFromEvents(events) {
|
|
605
868
|
const out = [];
|
|
606
|
-
for (const
|
|
607
|
-
const event = raw;
|
|
869
|
+
for (const event of events) {
|
|
608
870
|
if (event.type !== "TEXT_MESSAGE_CONTENT")
|
|
609
871
|
continue;
|
|
610
872
|
const data = asRecord(event.data);
|
|
@@ -615,11 +877,10 @@ function assistantTextEntriesFromEvents(events) {
|
|
|
615
877
|
const messageId = typeof data.messageId === "string" ? data.messageId : undefined;
|
|
616
878
|
if (messageId !== undefined)
|
|
617
879
|
entry.messageId = messageId;
|
|
618
|
-
if (typeof event.
|
|
619
|
-
entry.seq = event.
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
entry.recordedAt = recordedAt;
|
|
880
|
+
if (typeof event.sequence === "number")
|
|
881
|
+
entry.seq = event.sequence;
|
|
882
|
+
if (typeof event.time === "string")
|
|
883
|
+
entry.recordedAt = event.time;
|
|
623
884
|
out.push(entry);
|
|
624
885
|
}
|
|
625
886
|
return out;
|
|
@@ -641,10 +902,10 @@ function toolCallsFromEvents(events) {
|
|
|
641
902
|
const messageId = typeof data.messageId === "string" ? data.messageId : undefined;
|
|
642
903
|
if (messageId !== undefined)
|
|
643
904
|
trace.messageId = messageId;
|
|
644
|
-
if (typeof event.
|
|
645
|
-
trace.startSeq = event.
|
|
646
|
-
if (typeof event.
|
|
647
|
-
trace.startedAt = event.
|
|
905
|
+
if (typeof event.sequence === "number")
|
|
906
|
+
trace.startSeq = event.sequence;
|
|
907
|
+
if (typeof event.time === "string")
|
|
908
|
+
trace.startedAt = event.time;
|
|
648
909
|
if (!byId.has(id))
|
|
649
910
|
order.push(id);
|
|
650
911
|
byId.set(id, trace);
|
|
@@ -658,10 +919,10 @@ function toolCallsFromEvents(events) {
|
|
|
658
919
|
isError: data.isError === true,
|
|
659
920
|
content: data.content ?? null
|
|
660
921
|
};
|
|
661
|
-
if (typeof event.
|
|
662
|
-
result.seq = event.
|
|
663
|
-
if (typeof event.
|
|
664
|
-
result.recordedAt = event.
|
|
922
|
+
if (typeof event.sequence === "number")
|
|
923
|
+
result.seq = event.sequence;
|
|
924
|
+
if (typeof event.time === "string")
|
|
925
|
+
result.recordedAt = event.time;
|
|
665
926
|
let trace = byId.get(id);
|
|
666
927
|
if (trace === undefined) {
|
|
667
928
|
trace = { id, name: "", args: {} };
|
|
@@ -676,10 +937,6 @@ function toolCallsFromEvents(events) {
|
|
|
676
937
|
}
|
|
677
938
|
return order.map((id) => byId.get(id));
|
|
678
939
|
}
|
|
679
|
-
/** True when a usage summary actually carries at least one token count. */
|
|
680
|
-
function hasUsageCounts(usage) {
|
|
681
|
-
return !!usage && Object.values(usage).some((n) => typeof n === "number");
|
|
682
|
-
}
|
|
683
940
|
function usageFromEvents(events) {
|
|
684
941
|
const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 };
|
|
685
942
|
let seen = false;
|
|
@@ -734,14 +991,43 @@ function durationMs(start, end) {
|
|
|
734
991
|
const delta = b - a;
|
|
735
992
|
return delta >= 0 ? delta : undefined;
|
|
736
993
|
}
|
|
994
|
+
const SESSION_TERMINAL_READS = new Set([
|
|
995
|
+
"succeeded",
|
|
996
|
+
"failed",
|
|
997
|
+
"timed_out",
|
|
998
|
+
"cancelled",
|
|
999
|
+
"idle",
|
|
1000
|
+
"suspended",
|
|
1001
|
+
"awaiting_approval"
|
|
1002
|
+
]);
|
|
1003
|
+
/** The CUSTOM `aex.session.<name>` terminal event names → the carried read. */
|
|
1004
|
+
const SESSION_TERMINAL_EVENT_READS = {
|
|
1005
|
+
"aex.session.succeeded": "succeeded",
|
|
1006
|
+
"aex.session.failed": "failed",
|
|
1007
|
+
// Clean-cut: the bare `error` park is now `failed` (one terminal vocabulary).
|
|
1008
|
+
"aex.session.error": "failed",
|
|
1009
|
+
"aex.session.timed_out": "timed_out",
|
|
1010
|
+
"aex.session.cancelled": "cancelled",
|
|
1011
|
+
"aex.session.idle": "idle",
|
|
1012
|
+
"aex.session.suspended": "suspended",
|
|
1013
|
+
"aex.session.awaiting_approval": "awaiting_approval"
|
|
1014
|
+
};
|
|
1015
|
+
/** An explicit `data.value.outcome` a park/settle event may carry (the authoritative outcome). */
|
|
1016
|
+
function carriedOutcome(event) {
|
|
1017
|
+
const value = event.data.value;
|
|
1018
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1019
|
+
return undefined;
|
|
1020
|
+
const outcome = value.outcome;
|
|
1021
|
+
return typeof outcome === "string" && SESSION_TERMINAL_READS.has(outcome)
|
|
1022
|
+
? outcome
|
|
1023
|
+
: undefined;
|
|
1024
|
+
}
|
|
737
1025
|
function isSessionTurnTerminalEvent(event, turnSeq) {
|
|
738
1026
|
if (event.type === "RUN_FINISHED" || event.type === "RUN_ERROR") {
|
|
739
1027
|
return true;
|
|
740
1028
|
}
|
|
741
1029
|
const name = customName(event);
|
|
742
|
-
if (name
|
|
743
|
-
name !== "aex.session.suspended" &&
|
|
744
|
-
name !== "aex.session.error") {
|
|
1030
|
+
if (name === null || !(name in SESSION_TERMINAL_EVENT_READS)) {
|
|
745
1031
|
return false;
|
|
746
1032
|
}
|
|
747
1033
|
const value = event.data.value;
|
|
@@ -751,27 +1037,31 @@ function isSessionTurnTerminalEvent(event, turnSeq) {
|
|
|
751
1037
|
const eventTurnSeq = value.turnSeq;
|
|
752
1038
|
return typeof eventTurnSeq !== "number" || eventTurnSeq === turnSeq;
|
|
753
1039
|
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Read the CARRIED terminal outcome/park from the turn's terminal event — never
|
|
1042
|
+
* a lossy re-derivation. Prefers an explicit `data.value.outcome`, then the
|
|
1043
|
+
* `aex.session.<name>` name, then RUN_ERROR→failed / RUN_FINISHED→succeeded.
|
|
1044
|
+
*/
|
|
754
1045
|
function terminalSessionStatusFromEvents(events, turnSeq) {
|
|
755
1046
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
756
1047
|
const event = events[i];
|
|
757
1048
|
if (!isSessionTurnTerminalEvent(event, turnSeq))
|
|
758
1049
|
continue;
|
|
1050
|
+
const carried = carriedOutcome(event);
|
|
1051
|
+
if (carried !== undefined)
|
|
1052
|
+
return carried;
|
|
759
1053
|
if (event.type === "RUN_ERROR")
|
|
760
|
-
return "
|
|
1054
|
+
return "failed";
|
|
761
1055
|
if (event.type === "RUN_FINISHED")
|
|
762
|
-
return "
|
|
1056
|
+
return "succeeded";
|
|
763
1057
|
const name = customName(event);
|
|
764
|
-
if (name
|
|
765
|
-
return
|
|
766
|
-
if (name === "aex.session.suspended")
|
|
767
|
-
return "suspended";
|
|
768
|
-
if (name === "aex.session.error")
|
|
769
|
-
return "error";
|
|
1058
|
+
if (name !== null && name in SESSION_TERMINAL_EVENT_READS)
|
|
1059
|
+
return SESSION_TERMINAL_EVENT_READS[name];
|
|
770
1060
|
}
|
|
771
1061
|
return undefined;
|
|
772
1062
|
}
|
|
773
|
-
function withTerminalSessionStatus(session,
|
|
774
|
-
if (
|
|
1063
|
+
function withTerminalSessionStatus(session, read) {
|
|
1064
|
+
if (read === undefined || session.status === read)
|
|
775
1065
|
return session;
|
|
776
1066
|
if (session.status !== "creating" &&
|
|
777
1067
|
session.status !== "running" &&
|
|
@@ -779,7 +1069,108 @@ function withTerminalSessionStatus(session, terminalStatus) {
|
|
|
779
1069
|
session.status !== "cancelling") {
|
|
780
1070
|
return session;
|
|
781
1071
|
}
|
|
782
|
-
return { ...session, status:
|
|
1072
|
+
return { ...session, status: read };
|
|
1073
|
+
}
|
|
1074
|
+
/** Map a terminal READ to the 4-value {@link SessionTerminalOutcome} for the result `status`. */
|
|
1075
|
+
function readToOutcome(read) {
|
|
1076
|
+
return read === "failed" || read === "timed_out" || read === "cancelled" ? read : "succeeded";
|
|
1077
|
+
}
|
|
1078
|
+
/** `true` for an OK terminal read (succeeded / resumable idle|suspended / held awaiting_approval). */
|
|
1079
|
+
function isTerminalReadOk(read) {
|
|
1080
|
+
return read !== "failed" && read !== "timed_out" && read !== "cancelled";
|
|
1081
|
+
}
|
|
1082
|
+
/** Best-effort READ off a settled record when the events carried none. */
|
|
1083
|
+
function sessionRecordRead(session) {
|
|
1084
|
+
const outcome = session.lastTurnOutcome;
|
|
1085
|
+
if (outcome !== undefined && SESSION_TERMINAL_READS.has(outcome))
|
|
1086
|
+
return outcome;
|
|
1087
|
+
const status = session.status;
|
|
1088
|
+
if (status === "idle" || status === "suspended" || status === "awaiting_approval")
|
|
1089
|
+
return status;
|
|
1090
|
+
if (status === "succeeded" || status === "failed" || status === "timed_out" || status === "cancelled")
|
|
1091
|
+
return status;
|
|
1092
|
+
if (status === "error")
|
|
1093
|
+
return "failed";
|
|
1094
|
+
return "succeeded";
|
|
1095
|
+
}
|
|
1096
|
+
/** The settle-written provider usage entries (the SINGLE token-usage source). */
|
|
1097
|
+
function providerUsageOf(record) {
|
|
1098
|
+
const telemetry = record.costTelemetry;
|
|
1099
|
+
return telemetry?.providerUsage;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* The IMMEDIATE authoritative failure text — the terminal `RUN_ERROR` event's
|
|
1103
|
+
* `data.failureMessage`. `result.error` reads this FIRST so a failed (e.g.
|
|
1104
|
+
* bad-BYOK) run's error is never empty even before the settle-lagged
|
|
1105
|
+
* `sessionRecord.errorMessage` lands.
|
|
1106
|
+
*/
|
|
1107
|
+
function failureFromEvents(events) {
|
|
1108
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1109
|
+
const event = events[i];
|
|
1110
|
+
if (event.type !== "RUN_ERROR")
|
|
1111
|
+
continue;
|
|
1112
|
+
const data = asRecord(event.data);
|
|
1113
|
+
for (const key of ["failureMessage", "message", "error"]) {
|
|
1114
|
+
const value = data[key];
|
|
1115
|
+
if (typeof value === "string" && value.length > 0)
|
|
1116
|
+
return value;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return undefined;
|
|
1120
|
+
}
|
|
1121
|
+
/** The typed schema-decode outcome from the terminal `aex.result.*` event, if any. */
|
|
1122
|
+
function outcomeFromEvents(events) {
|
|
1123
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1124
|
+
const event = events[i];
|
|
1125
|
+
if (event.isResultDecoded()) {
|
|
1126
|
+
const value = asRecord(event.data).value;
|
|
1127
|
+
const decoded = value && typeof value === "object" && !Array.isArray(value) && "value" in value
|
|
1128
|
+
? value.value
|
|
1129
|
+
: value;
|
|
1130
|
+
return { kind: "decoded", value: decoded };
|
|
1131
|
+
}
|
|
1132
|
+
if (event.isResultRefused()) {
|
|
1133
|
+
const payload = asRecord(asRecord(event.data).value);
|
|
1134
|
+
const reason = payload.reason;
|
|
1135
|
+
const detail = typeof payload.detail === "string" ? payload.detail : undefined;
|
|
1136
|
+
return {
|
|
1137
|
+
kind: "refused",
|
|
1138
|
+
reason: reason === "schema_violation" || reason === "uncertain" || reason === "refused" ? reason : "refused",
|
|
1139
|
+
...(detail !== undefined ? { detail } : {})
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Build the ONE unified settled turn result (shared by `session.send().done()`
|
|
1147
|
+
* and `Aex.run`): the terminal outcome `status`, `ok`, `costUsd` (>= 0),
|
|
1148
|
+
* `usage` (from `costTelemetry.providerUsage`), and event-first `error`.
|
|
1149
|
+
*/
|
|
1150
|
+
function settledTurnResult(sessionId, session, turn, events, outputs, messages, read) {
|
|
1151
|
+
const effectiveRead = read ?? sessionRecordRead(session);
|
|
1152
|
+
const status = readToOutcome(effectiveRead);
|
|
1153
|
+
const ok = isTerminalReadOk(effectiveRead);
|
|
1154
|
+
const usage = usageFromProviderUsage(providerUsageOf(session));
|
|
1155
|
+
const costUsd = typeof session.costUsd === "number" ? session.costUsd : 0;
|
|
1156
|
+
const error = failureFromEvents(events) ??
|
|
1157
|
+
(!ok && typeof session.errorMessage === "string" && session.errorMessage ? session.errorMessage : undefined);
|
|
1158
|
+
const outcome = outcomeFromEvents(events);
|
|
1159
|
+
return {
|
|
1160
|
+
sessionId,
|
|
1161
|
+
session,
|
|
1162
|
+
turn,
|
|
1163
|
+
status,
|
|
1164
|
+
ok,
|
|
1165
|
+
costUsd,
|
|
1166
|
+
usage,
|
|
1167
|
+
...(error !== undefined ? { error } : {}),
|
|
1168
|
+
text: assistantTextFromEvents(events),
|
|
1169
|
+
events,
|
|
1170
|
+
outputs,
|
|
1171
|
+
messages,
|
|
1172
|
+
...(outcome !== undefined ? { outcome } : {})
|
|
1173
|
+
};
|
|
783
1174
|
}
|
|
784
1175
|
/**
|
|
785
1176
|
* Workspace AgentsMd admin operations exposed under `client.agentsMd`.
|
|
@@ -924,6 +1315,8 @@ export class Aex {
|
|
|
924
1315
|
skills;
|
|
925
1316
|
secrets;
|
|
926
1317
|
sessions;
|
|
1318
|
+
/** Cross-run output search (`aex.outputs.search(...)`). */
|
|
1319
|
+
outputs;
|
|
927
1320
|
constructor(options, overrides = {}) {
|
|
928
1321
|
const resolved = typeof options === "string" ? { ...overrides, apiKey: options } : options;
|
|
929
1322
|
const apiKey = resolved.apiKey;
|
|
@@ -932,6 +1325,12 @@ export class Aex {
|
|
|
932
1325
|
// missing credential too, instead of a bare Error slipping the taxonomy.
|
|
933
1326
|
throw new CredentialValidationError("Aex: apiKey is required");
|
|
934
1327
|
}
|
|
1328
|
+
// Self-describing key ⇒ plane-aware routing, checked ZERO-network in the
|
|
1329
|
+
// constructor: derive the baseUrl from the key's plane when omitted, and
|
|
1330
|
+
// fail fast on a plane/baseUrl mismatch instead of a bare 401 after a full
|
|
1331
|
+
// round-trip. An opaque/legacy key (no `aex_` shape) skips this and keeps
|
|
1332
|
+
// the HttpClient default.
|
|
1333
|
+
const baseUrl = resolveBaseUrlForKey(apiKey, resolved.baseUrl);
|
|
935
1334
|
// Wrap the transport fetch (the caller's override, or global `fetch`) with
|
|
936
1335
|
// the bounded-retry layer so every BFF request gets default resilience.
|
|
937
1336
|
// The raw `#fetch` below stays unwrapped for the direct-to-storage asset PUT
|
|
@@ -939,7 +1338,7 @@ export class Aex {
|
|
|
939
1338
|
const baseFetch = resolved.fetch ?? ((input, init) => fetch(input, init));
|
|
940
1339
|
const retryingFetch = withRetry(baseFetch, resolved.retry);
|
|
941
1340
|
this.#http = new HttpClient({
|
|
942
|
-
...(
|
|
1341
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
943
1342
|
apiKey,
|
|
944
1343
|
fetch: retryingFetch,
|
|
945
1344
|
// Opt-in local diagnostics: emit a redacted per-request trace to
|
|
@@ -955,6 +1354,7 @@ export class Aex {
|
|
|
955
1354
|
this.skills = new SkillsClient(this.#http);
|
|
956
1355
|
this.secrets = new SecretsClient(this.#http);
|
|
957
1356
|
this.sessions = new SessionClient(this.#http, (options) => this.#buildSessionCreateRequest(options), this.#fetch);
|
|
1357
|
+
this.outputs = new OutputsClient(this.#http);
|
|
958
1358
|
}
|
|
959
1359
|
/**
|
|
960
1360
|
* Internal: satisfies the `SecretUploader` surface so a
|
|
@@ -982,6 +1382,21 @@ export class Aex {
|
|
|
982
1382
|
...(this.#fetch ? { fetch: this.#fetch } : {})
|
|
983
1383
|
});
|
|
984
1384
|
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Internal: materialize a LARGE draft (a `File.fromPath` over the streaming
|
|
1387
|
+
* threshold) to the content store via the two-pass streaming multipart flow —
|
|
1388
|
+
* hash the deterministic canonical-zip stream, presign by hash (dedup still
|
|
1389
|
+
* short-circuits), then upload it in parts. Bounded memory (one entry + one
|
|
1390
|
+
* part). NOT part of the public API.
|
|
1391
|
+
*/
|
|
1392
|
+
async _uploadAssetStream(args) {
|
|
1393
|
+
return uploadAssetMultipart({
|
|
1394
|
+
http: this.#http,
|
|
1395
|
+
drive: args.drive,
|
|
1396
|
+
...(args.contentType ? { contentType: args.contentType } : {}),
|
|
1397
|
+
...(this.#fetch ? { fetch: this.#fetch } : {})
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
985
1400
|
/**
|
|
986
1401
|
* Internal: upsert already-uploaded skill metadata into the workspace registry.
|
|
987
1402
|
* The bytes are staged through `_uploadAsset`; this call binds the content hash
|
|
@@ -1004,61 +1419,46 @@ export class Aex {
|
|
|
1004
1419
|
assertNoLegacySessionFields(options, "Aex.run");
|
|
1005
1420
|
const input = normaliseSessionInput(message, "Aex.run", "message");
|
|
1006
1421
|
assertNoSessionSendSignal(stream, "Aex.run stream");
|
|
1007
|
-
const
|
|
1422
|
+
const sendOptions = {
|
|
1008
1423
|
...(stream ?? {}),
|
|
1009
1424
|
...(scopedSignal?.signal ? { signal: scopedSignal.signal } : {}),
|
|
1010
1425
|
...(opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {}),
|
|
1011
1426
|
...(opts.idleTimeoutMs !== undefined ? { idleTimeoutMs: opts.idleTimeoutMs } : {}),
|
|
1012
|
-
...(opts.pingIntervalMs !== undefined ? { pingIntervalMs: opts.pingIntervalMs } : {})
|
|
1427
|
+
...(opts.pingIntervalMs !== undefined ? { pingIntervalMs: opts.pingIntervalMs } : {}),
|
|
1428
|
+
// run()/done() await settle by DEFAULT; opt out with `await: 'park'`.
|
|
1429
|
+
...(opts.await !== undefined ? { await: opts.await } : {})
|
|
1013
1430
|
};
|
|
1014
1431
|
// Derive the message key from the create key (like the CLI) so a retried
|
|
1015
1432
|
// run with the same `idempotencyKey` de-duplicates BOTH the create and the
|
|
1016
1433
|
// billable turn server-side — never a duplicate billable run (sdk-dx-3).
|
|
1017
|
-
const createKey = createOptions.idempotencyKey
|
|
1018
|
-
const messageKey = messageIdempotencyKey
|
|
1434
|
+
const createKey = operations.resolveIdempotencyKey(createOptions.idempotencyKey);
|
|
1435
|
+
const messageKey = messageIdempotencyKey !== undefined ? operations.resolveIdempotencyKey(messageIdempotencyKey) : deriveMessageKey(createKey);
|
|
1019
1436
|
const session = await this.sessions.create({ ...createOptions, idempotencyKey: createKey });
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1437
|
+
// ONE terminal boundary: `done()` awaits settle by default, so the turn
|
|
1438
|
+
// result already carries the terminal outcome + cost + usage. `run()` just
|
|
1439
|
+
// reshapes it — `done()` == `run()` (WS3).
|
|
1440
|
+
let turnResult;
|
|
1441
|
+
try {
|
|
1442
|
+
turnResult = await sendSessionInternal(session, input, { ...sendOptions, idempotencyKey: messageKey }).done();
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
if (scopedSignal?.signal.aborted) {
|
|
1446
|
+
// The client-side wait budget (opts.timeoutMs) expired. Parity with
|
|
1447
|
+
// SessionHandle.wait(): THROW rather than a misleading silent result;
|
|
1448
|
+
// the run continues server-side.
|
|
1449
|
+
throw new RunStateError(`Aex.run: timed out after ${opts.timeoutMs}ms waiting for run ${session.id} to park; the run ` +
|
|
1450
|
+
`continues server-side — cancel via session.cancel() or resume with openSession(${JSON.stringify(session.id)})`);
|
|
1451
|
+
}
|
|
1452
|
+
throw err;
|
|
1453
|
+
}
|
|
1024
1454
|
const runId = turnResult.sessionId;
|
|
1025
|
-
// Settle-consistent enrichment (opt-in): the park EVENT that ends the
|
|
1026
|
-
// stream lands tens of seconds BEFORE the settle write that flips the
|
|
1027
|
-
// record and stamps costTelemetry/costUsd, so an immediate read misses
|
|
1028
|
-
// the showback on virtually every fresh run. `settleConsistent: true`
|
|
1029
|
-
// polls for the parked RECORD (bounded; degrades to the immediate read).
|
|
1030
|
-
const settledRecord = opts.settleConsistent === true
|
|
1031
|
-
? await settledSessionRecord(this.#http, runId, turnResult.session, scopedSignal?.signal)
|
|
1032
|
-
: undefined;
|
|
1033
|
-
const sessionRecord = settledRecord ?? turnResult.session;
|
|
1034
1455
|
if (deleteAfter) {
|
|
1035
1456
|
await session.delete();
|
|
1036
1457
|
}
|
|
1458
|
+
const sessionRecord = turnResult.session;
|
|
1037
1459
|
const run = sessionToRun(sessionRecord);
|
|
1038
|
-
const
|
|
1039
|
-
const
|
|
1040
|
-
const ok = turnResult.status === "idle" || turnResult.status === "suspended";
|
|
1041
|
-
if (!ok && scopedSignal?.signal.aborted) {
|
|
1042
|
-
// The client-side wait budget (opts.timeoutMs) expired before the run
|
|
1043
|
-
// reached a terminal park. Parity with SessionHandle.wait(): THROW rather
|
|
1044
|
-
// than silently returning a misleading {ok:false,status:"running"} with no
|
|
1045
|
-
// error (pre-launch edge-sweep F3). The run continues server-side.
|
|
1046
|
-
throw new RunStateError(`Aex.run: timed out after ${opts.timeoutMs}ms waiting for run ${runId} to park (last status ` +
|
|
1047
|
-
`${JSON.stringify(turnResult.status)}); the run continues server-side — cancel via ` +
|
|
1048
|
-
`session.cancel() or resume with openSession(${JSON.stringify(runId)})`);
|
|
1049
|
-
}
|
|
1050
|
-
const trace = runTraceFromEvents(events);
|
|
1051
|
-
// Surface the trace-derived usage at the top level when the run record does
|
|
1052
|
-
// not carry its own usage (the managed plane doesn't populate session.usage);
|
|
1053
|
-
// the per-event trace still yields token counts (pre-launch edge-sweep F5).
|
|
1054
|
-
// When NEITHER source carries token counts (trace usage is `{}` — the
|
|
1055
|
-
// managed plane emits no `aex.usage` events today), leave `usage` absent so
|
|
1056
|
-
// `result.usage` honors its "when the deployment exposes it" contract
|
|
1057
|
-
// instead of surfacing a truthy-but-empty object.
|
|
1058
|
-
const recordUsage = hasUsageCounts(sessionRecord.usage) ? sessionRecord.usage : undefined;
|
|
1059
|
-
const usage = recordUsage ?? (hasUsageCounts(trace.usage) ? trace.usage : undefined);
|
|
1060
|
-
const costUsd = typeof sessionRecord.costUsd === "number" ? sessionRecord.costUsd : undefined;
|
|
1061
|
-
const errorMessage = typeof sessionRecord.errorMessage === "string" && sessionRecord.errorMessage ? sessionRecord.errorMessage : undefined;
|
|
1460
|
+
const trace = runTraceFromEvents(turnResult.events);
|
|
1461
|
+
const outcome = turnResult.outcome;
|
|
1062
1462
|
const result = {
|
|
1063
1463
|
runId,
|
|
1064
1464
|
run,
|
|
@@ -1066,17 +1466,18 @@ export class Aex {
|
|
|
1066
1466
|
session: sessionRecord,
|
|
1067
1467
|
turn: turnResult.turn,
|
|
1068
1468
|
status: turnResult.status,
|
|
1069
|
-
ok,
|
|
1469
|
+
ok: turnResult.ok,
|
|
1470
|
+
costUsd: turnResult.costUsd,
|
|
1471
|
+
usage: turnResult.usage,
|
|
1070
1472
|
text: turnResult.text,
|
|
1071
1473
|
messages: turnResult.messages,
|
|
1072
|
-
events,
|
|
1474
|
+
events: turnResult.events,
|
|
1073
1475
|
trace,
|
|
1074
|
-
outputs,
|
|
1075
|
-
...(
|
|
1076
|
-
...(
|
|
1077
|
-
...(!ok && errorMessage ? { error: errorMessage } : {})
|
|
1476
|
+
outputs: turnResult.outputs,
|
|
1477
|
+
...(turnResult.error !== undefined ? { error: turnResult.error } : {}),
|
|
1478
|
+
...(outcome !== undefined ? { outcome } : {})
|
|
1078
1479
|
};
|
|
1079
|
-
if (opts.throwOnFailure && !ok) {
|
|
1480
|
+
if (opts.throwOnFailure && !turnResult.ok) {
|
|
1080
1481
|
// A turn that failed because the upstream provider throttled us surfaces
|
|
1081
1482
|
// as a structured, non-leaky AexRateLimitError carrying the provider
|
|
1082
1483
|
// fault, so callers can branch on `isRateLimited(err)` and replay.
|
|
@@ -1090,7 +1491,7 @@ export class Aex {
|
|
|
1090
1491
|
...(throttle.retryAfterMs !== undefined ? { retryAfterMs: throttle.retryAfterMs } : {})
|
|
1091
1492
|
});
|
|
1092
1493
|
}
|
|
1093
|
-
throw new RunStateError(`Aex.run: session ${runId} ended ${turnResult.status}${
|
|
1494
|
+
throw new RunStateError(`Aex.run: session ${runId} ended ${turnResult.status}${turnResult.error ? `: ${turnResult.error}` : ""}`, { runId, status: turnResult.status });
|
|
1094
1495
|
}
|
|
1095
1496
|
return result;
|
|
1096
1497
|
}
|
|
@@ -1098,6 +1499,49 @@ export class Aex {
|
|
|
1098
1499
|
scopedSignal?.clear();
|
|
1099
1500
|
}
|
|
1100
1501
|
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Fire-and-forget: create the session and POST its first turn WITHOUT awaiting
|
|
1504
|
+
* settle (the honest counterpart to await-settle `run()`). Resolves with the
|
|
1505
|
+
* `runId` + a resumable {@link SessionHandle} immediately; observe the run via
|
|
1506
|
+
* a `webhook`, the event stream, or `openSession(runId)`.
|
|
1507
|
+
*/
|
|
1508
|
+
async submit(options) {
|
|
1509
|
+
const { message, deleteAfter: _deleteAfter, messageIdempotencyKey, stream: _stream, ...createOptions } = options;
|
|
1510
|
+
assertNoLegacySessionFields(options, "Aex.submit");
|
|
1511
|
+
const input = normaliseSessionInput(message, "Aex.submit", "message");
|
|
1512
|
+
const request = await this.#buildSessionCreateRequest(createOptions);
|
|
1513
|
+
const { runId, session } = await operations.submit(this.#http, { ...request, input }, {
|
|
1514
|
+
idempotencyKey: operations.resolveIdempotencyKey(createOptions.idempotencyKey),
|
|
1515
|
+
...(messageIdempotencyKey !== undefined ? { messageIdempotencyKey } : {})
|
|
1516
|
+
});
|
|
1517
|
+
return { runId, session: new SessionHandle(this.#http, session, this.#fetch) };
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Run a batch of one-shot items with a bounded worker pool, returning every
|
|
1521
|
+
* item's settled result PLUS a REAL cost/usage rollup. The rollup is honest
|
|
1522
|
+
* because each `run()` awaits settle, so each item's `costUsd`/`usage` is
|
|
1523
|
+
* populated — a failed item lands in `failed[]`, never a silent `$0` success.
|
|
1524
|
+
* Concurrency is clamped below the workspace tier cap.
|
|
1525
|
+
*/
|
|
1526
|
+
async batch(items, options = {}) {
|
|
1527
|
+
if (!Array.isArray(items)) {
|
|
1528
|
+
throw new RunConfigValidationError("Aex.batch: items must be an array of run options");
|
|
1529
|
+
}
|
|
1530
|
+
const concurrency = Math.min(Math.max(1, Math.floor(options.concurrency ?? DEFAULT_BATCH_CONCURRENCY)), BATCH_MAX_CONCURRENCY);
|
|
1531
|
+
const results = (await mapWithConcurrency(items, concurrency, async (item) => {
|
|
1532
|
+
const result = await this.run(item);
|
|
1533
|
+
return {
|
|
1534
|
+
runId: result.runId,
|
|
1535
|
+
status: result.status,
|
|
1536
|
+
ok: result.ok,
|
|
1537
|
+
costUsd: result.costUsd,
|
|
1538
|
+
usage: result.usage,
|
|
1539
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
1540
|
+
...(result.outcome !== undefined ? { outcome: result.outcome } : {})
|
|
1541
|
+
};
|
|
1542
|
+
}));
|
|
1543
|
+
return rollupBatch(results);
|
|
1544
|
+
}
|
|
1101
1545
|
openSession(optionsOrId) {
|
|
1102
1546
|
return typeof optionsOrId === "string"
|
|
1103
1547
|
? this.sessions.open(optionsOrId)
|
|
@@ -1116,31 +1560,33 @@ export class Aex {
|
|
|
1116
1560
|
throw new RunConfigValidationError("Aex.openSession: message is not a supported option; sessions are created without a first " +
|
|
1117
1561
|
"message — send it with session.send(...), or use run({ message }) for a one-shot.");
|
|
1118
1562
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1563
|
+
// Model is REQUIRED and checked BEFORE the provider key, so omitting `model`
|
|
1564
|
+
// reports "model is required" rather than a misleading provider-key message.
|
|
1565
|
+
if (typeof options.model !== "string" || !options.model) {
|
|
1566
|
+
throw configError("Aex.openSession", "model", "model is required");
|
|
1567
|
+
}
|
|
1568
|
+
// One model→provider resolver (SSoT), shared with the CLI: it honors an
|
|
1569
|
+
// explicit provider (forward-compat: an unknown model is allowed through so a
|
|
1570
|
+
// slightly-old SDK can still run a newly-launched model), infers the default
|
|
1571
|
+
// provider for a known model, and — for an UNKNOWN model with no provider —
|
|
1572
|
+
// throws a shared `did you mean?` suggestion.
|
|
1573
|
+
let provider;
|
|
1574
|
+
try {
|
|
1575
|
+
provider = resolveModelProvider(options.model, options.provider);
|
|
1125
1576
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
supportedProviders.length === 0 &&
|
|
1129
|
-
typeof options.model === "string" &&
|
|
1130
|
-
options.model.length > 0 &&
|
|
1131
|
-
(typeof options.apiKeys?.[provider] !== "string" || options.apiKeys[provider].length === 0)) {
|
|
1132
|
-
// Unknown model with no explicit provider: the DEFAULT_RUN_PROVIDER
|
|
1133
|
-
// fallback exists for forward-compat with models added server-side, but
|
|
1134
|
-
// without a key for that default the generic missing-key error below
|
|
1135
|
-
// would point at the wrong problem (e.g. "pass apiKeys[\"anthropic\"]"
|
|
1136
|
-
// when the caller mistyped a deepseek model id). Name the real issue.
|
|
1137
|
-
throw new RunConfigValidationError(`Aex.openSession: model ${JSON.stringify(options.model)} is not a known model id, so its provider ` +
|
|
1138
|
-
`cannot be inferred — pass provider explicitly (with a matching apiKeys entry) to run a model ` +
|
|
1139
|
-
`this SDK version does not know about.`);
|
|
1577
|
+
catch (err) {
|
|
1578
|
+
throw configError("Aex.openSession", options.provider === undefined ? "model" : "provider", err instanceof Error ? err.message : String(err), options.provider ?? options.model);
|
|
1140
1579
|
}
|
|
1141
1580
|
validateApiKeys(options.apiKeys, provider, "Aex.openSession");
|
|
1142
|
-
|
|
1143
|
-
|
|
1581
|
+
// WS9 fail-closed: `outputMode:'stream'` on a NON-streamable provider is a
|
|
1582
|
+
// hard reject at the earliest seam (no silent downgrade to buffered).
|
|
1583
|
+
if (options.outputMode !== undefined) {
|
|
1584
|
+
try {
|
|
1585
|
+
assertStreamableOutputMode(options.outputMode, provider);
|
|
1586
|
+
}
|
|
1587
|
+
catch (err) {
|
|
1588
|
+
throw configError("Aex.openSession", "outputMode", err instanceof Error ? err.message : String(err), options.outputMode);
|
|
1589
|
+
}
|
|
1144
1590
|
}
|
|
1145
1591
|
// Fast client-side validation via the contract parsers (the SSoT). runtimeSize
|
|
1146
1592
|
// and timeout are STABLE closed sets whose invalid values the create endpoint
|
|
@@ -1161,22 +1607,28 @@ export class Aex {
|
|
|
1161
1607
|
}
|
|
1162
1608
|
const { declarations: secretEnvDeclarations, values: envSecretValues } = splitSecretEnv(options.environment?.secrets);
|
|
1163
1609
|
let limits;
|
|
1610
|
+
const limitsInput = {};
|
|
1611
|
+
if (options.overrides?.maxSpendUsd !== undefined)
|
|
1612
|
+
limitsInput.maxSpendUsd = options.overrides.maxSpendUsd;
|
|
1613
|
+
if (options.overrides?.maxTurns !== undefined)
|
|
1614
|
+
limitsInput.maxTurns = options.overrides.maxTurns;
|
|
1164
1615
|
try {
|
|
1165
|
-
limits = parseRunLimits(
|
|
1166
|
-
? undefined
|
|
1167
|
-
: { maxSpendUsd: options.overrides.maxSpendUsd });
|
|
1616
|
+
limits = parseRunLimits(Object.keys(limitsInput).length > 0 ? limitsInput : undefined);
|
|
1168
1617
|
}
|
|
1169
1618
|
catch (err) {
|
|
1170
|
-
|
|
1619
|
+
// One `configError` factory for every client-side validation throw, so
|
|
1620
|
+
// maxSpendUsd/maxTurns are `RunConfigValidationError` (not a base AexError).
|
|
1621
|
+
throw configError("Aex.openSession", "limits", err instanceof Error ? err.message : String(err));
|
|
1171
1622
|
}
|
|
1172
1623
|
const uploader = (args) => this._uploadAsset(args);
|
|
1624
|
+
const streamUploader = (args) => this._uploadAssetStream(args);
|
|
1173
1625
|
// The four prepare passes touch disjoint instance sets, so run them
|
|
1174
1626
|
// concurrently; each internally uploads its drafts with bounded concurrency.
|
|
1175
1627
|
const [preparedTools, preparedSkills, preparedAgentsMd, preparedFiles] = await Promise.all([
|
|
1176
1628
|
prepareTools(options.tools ?? [], uploader),
|
|
1177
1629
|
prepareSkills(options.skills ?? [], this),
|
|
1178
1630
|
prepareAgentsMd(options.agentsMd ?? [], uploader),
|
|
1179
|
-
prepareFiles(options.files ?? [], uploader)
|
|
1631
|
+
prepareFiles(options.files ?? [], uploader, streamUploader)
|
|
1180
1632
|
]);
|
|
1181
1633
|
const { submissionMcpServers, mergedMcpSecrets } = mergeMcpServers(options.mcpServers ?? [], []);
|
|
1182
1634
|
const outputCapture = outputsForWire(options.outputs);
|
|
@@ -1201,7 +1653,9 @@ export class Aex {
|
|
|
1201
1653
|
...(options.includeBuiltinTools !== undefined
|
|
1202
1654
|
? { includeBuiltinTools: options.includeBuiltinTools }
|
|
1203
1655
|
: {}),
|
|
1204
|
-
...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {})
|
|
1656
|
+
...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {}),
|
|
1657
|
+
...(options.responseFormat !== undefined ? { responseFormat: options.responseFormat } : {}),
|
|
1658
|
+
...(options.approvalGate !== undefined ? { approvalGate: options.approvalGate } : {})
|
|
1205
1659
|
};
|
|
1206
1660
|
const secrets = {
|
|
1207
1661
|
...(options.apiKeys ? { apiKeys: options.apiKeys } : {}),
|
|
@@ -1277,6 +1731,35 @@ export class Aex {
|
|
|
1277
1731
|
return operations.getWebhookSigningSecret(this.#http);
|
|
1278
1732
|
}
|
|
1279
1733
|
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Resolve the effective `baseUrl` from a self-describing API key (ZERO-network):
|
|
1736
|
+
* - key omitted-of-shape (opaque/legacy): return the caller's `baseUrl`
|
|
1737
|
+
* unchanged (HttpClient falls back to the prd default).
|
|
1738
|
+
* - `baseUrl` omitted: DERIVE it from the key's plane; a plane with no default
|
|
1739
|
+
* host (dev) requires an explicit `baseUrl` — throw with that guidance.
|
|
1740
|
+
* - `baseUrl` supplied but its plane DISAGREES with the key's plane: throw
|
|
1741
|
+
* {@link CredentialValidationError} BEFORE any request (the exact `dev key
|
|
1742
|
+
* against the prd default → bare token_invalid` trap).
|
|
1743
|
+
*/
|
|
1744
|
+
function resolveBaseUrlForKey(apiKey, baseUrl) {
|
|
1745
|
+
const parsed = parseApiKey(apiKey);
|
|
1746
|
+
if (parsed === null)
|
|
1747
|
+
return baseUrl;
|
|
1748
|
+
const planeUrl = PLANE_BASE_URLS[parsed.plane];
|
|
1749
|
+
if (baseUrl === undefined) {
|
|
1750
|
+
if (planeUrl === null) {
|
|
1751
|
+
throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane, which has no default host — pass baseUrl explicitly.`, { plane: parsed.plane });
|
|
1752
|
+
}
|
|
1753
|
+
return planeUrl;
|
|
1754
|
+
}
|
|
1755
|
+
// The only client-detectable mismatch: a non-prd key pointed at the canonical
|
|
1756
|
+
// prd host (dev has no canonical host to compare a prd key against).
|
|
1757
|
+
if (baseUrl === PLANE_BASE_URLS.prd && parsed.plane !== "prd") {
|
|
1758
|
+
throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane but baseUrl targets the prd plane (${PLANE_BASE_URLS.prd}) — ` +
|
|
1759
|
+
`pass the ${parsed.plane} plane baseUrl, or omit baseUrl to auto-route.`, { plane: parsed.plane, baseUrl });
|
|
1760
|
+
}
|
|
1761
|
+
return baseUrl;
|
|
1762
|
+
}
|
|
1280
1763
|
// `Run.status` is a loose `string` on the wire shape, so we membership-test
|
|
1281
1764
|
// against the canonical terminal set rather than re-deriving one (which is how
|
|
1282
1765
|
// `timed_out` got dropped from the old hardcoded list).
|
|
@@ -1289,21 +1772,33 @@ const SETTLE_POLL_DEADLINE_MS = 60_000;
|
|
|
1289
1772
|
/** Interval between settle-poll reads (ms). */
|
|
1290
1773
|
const SETTLE_POLL_INTERVAL_MS = 750;
|
|
1291
1774
|
/**
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1775
|
+
* A settle marker on a record: the settle commit stamps `settledAt` (and always
|
|
1776
|
+
* `costUsd`, default 0) plus the terminal outcome / cost telemetry. The SAME
|
|
1777
|
+
* predicate gates the early-return and the loop-return so a $0 turn (costUsd:0
|
|
1778
|
+
* present) resolves as settled — never a hang, never an undefined cost.
|
|
1779
|
+
*/
|
|
1780
|
+
function isSettledRecord(record) {
|
|
1781
|
+
if (!isSessionParked(record.status))
|
|
1782
|
+
return false;
|
|
1783
|
+
return (record.settledAt !== undefined ||
|
|
1784
|
+
typeof record.costUsd === "number" ||
|
|
1785
|
+
record.lastTurnOutcome !== undefined ||
|
|
1786
|
+
providerUsageOf(record) !== undefined);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Poll for the session RECORD to reach a SETTLED state — i.e. for the settle
|
|
1790
|
+
* write (which stamps `settledAt` + `costUsd` + the terminal outcome) to land.
|
|
1791
|
+
* Returns the settled record, or `undefined` on timeout/abort/read-failure so
|
|
1792
|
+
* the caller can fall back to the record it already holds. The first read is
|
|
1296
1793
|
* immediate, so a fast settle costs one extra GET and no added latency.
|
|
1297
1794
|
*/
|
|
1298
1795
|
async function settledSessionRecord(http, sessionId, lastSeen, signal) {
|
|
1299
|
-
|
|
1300
|
-
// only trust it when it is parked AND already carries the settle-stamped cost.
|
|
1301
|
-
if (isSessionParked(lastSeen.status) && typeof lastSeen.costUsd === "number")
|
|
1796
|
+
if (isSettledRecord(lastSeen))
|
|
1302
1797
|
return lastSeen;
|
|
1303
1798
|
const deadline = Date.now() + SETTLE_POLL_DEADLINE_MS;
|
|
1304
1799
|
while (signal?.aborted !== true && Date.now() < deadline) {
|
|
1305
1800
|
const record = await operations.getSession(http, sessionId).catch(() => undefined);
|
|
1306
|
-
if (record !== undefined &&
|
|
1801
|
+
if (record !== undefined && isSettledRecord(record))
|
|
1307
1802
|
return record;
|
|
1308
1803
|
try {
|
|
1309
1804
|
await sleep(SETTLE_POLL_INTERVAL_MS, signal);
|
|
@@ -1314,6 +1809,28 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
|
|
|
1314
1809
|
}
|
|
1315
1810
|
return undefined;
|
|
1316
1811
|
}
|
|
1812
|
+
/**
|
|
1813
|
+
* How long a follow-up `send()` waits for the session RECORD to catch up after
|
|
1814
|
+
* our own turn parked idle before treating the `session_busy` as a genuinely
|
|
1815
|
+
* in-flight turn (ms). The idle park EVENT ends the turn stream, but the record
|
|
1816
|
+
* commit lags briefly behind it.
|
|
1817
|
+
*/
|
|
1818
|
+
const SESSION_BUSY_RECONCILE_DEADLINE_MS = 30_000;
|
|
1819
|
+
/** Interval between record reads while reconciling a settle-lag `session_busy` (ms). */
|
|
1820
|
+
const SESSION_BUSY_RECONCILE_INTERVAL_MS = 500;
|
|
1821
|
+
/**
|
|
1822
|
+
* A 409 `session_busy` whose CURRENT status is `running` — the one case that is
|
|
1823
|
+
* transient: the platform is still catching up from our own just-parked turn.
|
|
1824
|
+
* The 409 body carries `{ error:"session_busy", status:<current> }`; any other
|
|
1825
|
+
* busy status (suspended / cancelling / deleted) is a real, non-retryable
|
|
1826
|
+
* rejection that must surface immediately.
|
|
1827
|
+
*/
|
|
1828
|
+
function isSettlingSessionBusy(err) {
|
|
1829
|
+
if (!(err instanceof AexApiError) || err.status !== 409)
|
|
1830
|
+
return false;
|
|
1831
|
+
const body = asRecord(err.body);
|
|
1832
|
+
return body.error === "session_busy" && body.status === "running";
|
|
1833
|
+
}
|
|
1317
1834
|
/**
|
|
1318
1835
|
* A session is "parked" once it stops making progress: it reached one of the
|
|
1319
1836
|
* turn-terminal statuses (`idle` / `suspended` / `error`) or a terminal run
|
|
@@ -1322,6 +1839,7 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
|
|
|
1322
1839
|
function isSessionParked(status) {
|
|
1323
1840
|
return (status === "idle" ||
|
|
1324
1841
|
status === "suspended" ||
|
|
1842
|
+
status === "awaiting_approval" ||
|
|
1325
1843
|
status === "error" ||
|
|
1326
1844
|
status === "deleted" ||
|
|
1327
1845
|
status === "expired" ||
|
|
@@ -1352,49 +1870,6 @@ function scopedAbortSignal(timeoutMs) {
|
|
|
1352
1870
|
}
|
|
1353
1871
|
};
|
|
1354
1872
|
}
|
|
1355
|
-
/** Escape a literal string for safe interpolation into a RegExp. */
|
|
1356
|
-
function escapeRegExp(input) {
|
|
1357
|
-
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1358
|
-
}
|
|
1359
|
-
function isOutputPathSelector(selector) {
|
|
1360
|
-
return Boolean(selector && typeof selector === "object" && "path" in selector);
|
|
1361
|
-
}
|
|
1362
|
-
function resolveOutputFileSelector(outputs, selector, runId) {
|
|
1363
|
-
if (isOutputPathSelector(selector)) {
|
|
1364
|
-
const target = normalizeOutputLookupPath(selector.path);
|
|
1365
|
-
if (!target) {
|
|
1366
|
-
throw new RunStateError("Aex.downloadOutput: output path must be non-empty", {
|
|
1367
|
-
runId,
|
|
1368
|
-
path: selector.path
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
const matches = outputs.filter((output) => {
|
|
1372
|
-
if (typeof output.filename !== "string")
|
|
1373
|
-
return false;
|
|
1374
|
-
const filename = normalizeOutputLookupPath(output.filename);
|
|
1375
|
-
if (selector.match === "suffix") {
|
|
1376
|
-
return filename === target || filename.endsWith(`/${target}`);
|
|
1377
|
-
}
|
|
1378
|
-
return filename === target;
|
|
1379
|
-
});
|
|
1380
|
-
if (matches.length === 1)
|
|
1381
|
-
return matches[0];
|
|
1382
|
-
if (matches.length > 1) {
|
|
1383
|
-
throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" matched multiple files`, { runId, path: selector.path, matches: matches.map((output) => output.filename ?? output.id) });
|
|
1384
|
-
}
|
|
1385
|
-
throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" was not found`, {
|
|
1386
|
-
runId,
|
|
1387
|
-
path: selector.path
|
|
1388
|
-
});
|
|
1389
|
-
}
|
|
1390
|
-
if (typeof selector.id !== "string" || selector.id.length === 0) {
|
|
1391
|
-
throw new RunStateError("Aex.downloadOutput: selector must include an output id or path", { runId });
|
|
1392
|
-
}
|
|
1393
|
-
return { ...selector, id: selector.id };
|
|
1394
|
-
}
|
|
1395
|
-
function normalizeOutputLookupPath(path) {
|
|
1396
|
-
return path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1397
|
-
}
|
|
1398
1873
|
async function writeOptionalFile(bytes, to) {
|
|
1399
1874
|
if (to !== undefined) {
|
|
1400
1875
|
const { writeFile } = await import("node:fs/promises");
|
|
@@ -1419,11 +1894,13 @@ function sleep(ms, signal) {
|
|
|
1419
1894
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1420
1895
|
});
|
|
1421
1896
|
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1897
|
+
/**
|
|
1898
|
+
* ONE factory for every client-side validation throw: a
|
|
1899
|
+
* {@link RunConfigValidationError} carrying structured `details: { field, value? }`
|
|
1900
|
+
* so callers branch on `err.details.field` instead of string-parsing the message.
|
|
1901
|
+
*/
|
|
1902
|
+
function configError(surface, field, message, value) {
|
|
1903
|
+
return new RunConfigValidationError(`${surface}: ${message}`, value === undefined ? { field } : { field, value });
|
|
1427
1904
|
}
|
|
1428
1905
|
/**
|
|
1429
1906
|
* Derive the message idempotency key from the session-create key. Mirrors the
|
|
@@ -1478,7 +1955,7 @@ function normaliseSessionInput(input, surface, field) {
|
|
|
1478
1955
|
}
|
|
1479
1956
|
function assertNoLegacySessionFields(options, surface) {
|
|
1480
1957
|
const record = options;
|
|
1481
|
-
const removedProxyField = "proxy"
|
|
1958
|
+
const removedProxyField = ["proxy", "Endpoints"].join("");
|
|
1482
1959
|
const messages = {
|
|
1483
1960
|
input: "send user messages with session.send(...) or use run({ message }).",
|
|
1484
1961
|
prompt: "use message for one-shot run input or session.send(...) for follow-up messages.",
|
|
@@ -1489,9 +1966,9 @@ function assertNoLegacySessionFields(options, surface) {
|
|
|
1489
1966
|
secretEnv: "use environment.secrets.",
|
|
1490
1967
|
secrets: "use top-level apiKeys for provider keys and environment.secrets for run secrets.",
|
|
1491
1968
|
runtimeSize: "use runtime.",
|
|
1492
|
-
parentRunId: "subagents are session-internal; parentRunId is not part of the session API.",
|
|
1493
1969
|
limits: "use overrides.",
|
|
1494
1970
|
timeout: "use overrides.timeout.",
|
|
1971
|
+
parentRunId: "subagent lineage is assigned by the platform.",
|
|
1495
1972
|
signal: "use session.cancel() / session.suspend() for remote control.",
|
|
1496
1973
|
postHook: "send a follow-up validation message when the session returns idle.",
|
|
1497
1974
|
[removedProxyField]: "proxy endpoints are not part of the public SDK session API."
|
|
@@ -1701,13 +2178,24 @@ async function prepareAgentsMd(agentsMds, uploader) {
|
|
|
1701
2178
|
});
|
|
1702
2179
|
}
|
|
1703
2180
|
/** Walk File[], eagerly upload drafts as assets (bounded concurrency), and return plain asset refs. */
|
|
1704
|
-
async function prepareFiles(files, uploader) {
|
|
2181
|
+
async function prepareFiles(files, uploader, streamUploader) {
|
|
1705
2182
|
return mapWithConcurrency(files, UPLOAD_CONCURRENCY, async (entry, i) => {
|
|
1706
2183
|
if (!(entry instanceof File)) {
|
|
1707
2184
|
throw new RunConfigValidationError(`aex: files[${i}] must be a File instance`);
|
|
1708
2185
|
}
|
|
1709
2186
|
const ref = entry.ref;
|
|
1710
2187
|
if (ref.kind === "draft") {
|
|
2188
|
+
// A large draft carries a streaming driver instead of in-memory bytes.
|
|
2189
|
+
const stream = entry._takeDraftStream();
|
|
2190
|
+
if (stream) {
|
|
2191
|
+
const cached = entry._cachedAssetId;
|
|
2192
|
+
if (cached !== undefined) {
|
|
2193
|
+
return { kind: "asset", assetId: cached, name: stream.name, mountPath: stream.mountPath };
|
|
2194
|
+
}
|
|
2195
|
+
const uploaded = await streamUploader({ drive: stream.drive, contentType: "application/zip" });
|
|
2196
|
+
entry._rememberAsset(uploaded.assetId);
|
|
2197
|
+
return { kind: "asset", assetId: uploaded.assetId, name: stream.name, mountPath: stream.mountPath };
|
|
2198
|
+
}
|
|
1711
2199
|
const bundle = entry._takeDraftBundle();
|
|
1712
2200
|
if (!bundle) {
|
|
1713
2201
|
throw new RunConfigValidationError(`aex: files[${i}] is draft but has no bytes`);
|