@aexhq/sdk 0.38.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -17
- 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 +70 -2
- package/dist/_contracts/operations.js +143 -7
- package/dist/_contracts/run-config.d.ts +35 -10
- package/dist/_contracts/run-config.js +17 -5
- package/dist/_contracts/run-record.d.ts +3 -2
- package/dist/_contracts/runtime-types.d.ts +148 -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 +157 -14
- package/dist/_contracts/submission.js +319 -55
- package/dist/_contracts/suggest.d.ts +15 -0
- package/dist/_contracts/suggest.js +54 -0
- package/dist/asset-upload.d.ts +27 -1
- package/dist/asset-upload.js +219 -4
- package/dist/asset-upload.js.map +1 -1
- package/dist/bundle.d.ts +23 -14
- package/dist/bundle.js +39 -20
- 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 +1927 -326
- package/dist/cli.mjs.sha256 +1 -1
- package/dist/client.d.ts +273 -70
- package/dist/client.js +885 -335
- package/dist/client.js.map +1 -1
- package/dist/fetch-archive.js +14 -14
- package/dist/fetch-archive.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 +23 -14
- package/dist/index.js +31 -14
- 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 +151 -0
- package/dist/skill.js +298 -0
- package/dist/skill.js.map +1 -0
- 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/concepts/composition.md +3 -3
- 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/mcp.md +3 -2
- package/docs/networking.md +7 -1
- package/docs/outputs.md +36 -7
- package/docs/provider-runtime-capabilities.md +1 -1
- package/docs/quickstart.md +17 -7
- package/docs/run-config.md +3 -3
- package/docs/secrets.md +14 -0
- package/docs/skills.md +74 -44
- package/docs/vision-skills.md +3 -3
- 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/skill-tool.d.ts +0 -102
- package/dist/skill-tool.js +0 -190
- package/dist/skill-tool.js.map +0 -1
package/dist/client.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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";
|
|
7
7
|
import { splitSecretEnv } from "./secret.js";
|
|
8
|
-
import {
|
|
8
|
+
import { Skill } from "./skill.js";
|
|
9
9
|
import { Tool } from "./tool.js";
|
|
10
10
|
export class SessionTurnStream {
|
|
11
11
|
#run;
|
|
@@ -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`.
|
|
@@ -827,6 +1218,28 @@ export class FilesClient {
|
|
|
827
1218
|
return operations.deleteFile(this.#http, fileId);
|
|
828
1219
|
}
|
|
829
1220
|
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Workspace skill registry operations exposed under `client.skills`.
|
|
1223
|
+
*
|
|
1224
|
+
* Session creation normally passes `Skill.from*(...)` instances directly in the
|
|
1225
|
+
* `skills` option (auto-upserted by name). This namespace is the metadata
|
|
1226
|
+
* read/delete surface for the named workspace skill registry.
|
|
1227
|
+
*/
|
|
1228
|
+
export class SkillsClient {
|
|
1229
|
+
#http;
|
|
1230
|
+
constructor(http) {
|
|
1231
|
+
this.#http = http;
|
|
1232
|
+
}
|
|
1233
|
+
list() {
|
|
1234
|
+
return operations.listSkills(this.#http);
|
|
1235
|
+
}
|
|
1236
|
+
get(name) {
|
|
1237
|
+
return operations.getSkill(this.#http, name);
|
|
1238
|
+
}
|
|
1239
|
+
delete(name) {
|
|
1240
|
+
return operations.deleteSkill(this.#http, name);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
830
1243
|
/**
|
|
831
1244
|
* Workspace secret management exposed under `client.secrets`, mirroring
|
|
832
1245
|
* `client.agentsMd` / `client.files`.
|
|
@@ -899,8 +1312,11 @@ export class Aex {
|
|
|
899
1312
|
#fetch;
|
|
900
1313
|
agentsMd;
|
|
901
1314
|
files;
|
|
1315
|
+
skills;
|
|
902
1316
|
secrets;
|
|
903
1317
|
sessions;
|
|
1318
|
+
/** Cross-run output search (`aex.outputs.search(...)`). */
|
|
1319
|
+
outputs;
|
|
904
1320
|
constructor(options, overrides = {}) {
|
|
905
1321
|
const resolved = typeof options === "string" ? { ...overrides, apiKey: options } : options;
|
|
906
1322
|
const apiKey = resolved.apiKey;
|
|
@@ -909,6 +1325,12 @@ export class Aex {
|
|
|
909
1325
|
// missing credential too, instead of a bare Error slipping the taxonomy.
|
|
910
1326
|
throw new CredentialValidationError("Aex: apiKey is required");
|
|
911
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);
|
|
912
1334
|
// Wrap the transport fetch (the caller's override, or global `fetch`) with
|
|
913
1335
|
// the bounded-retry layer so every BFF request gets default resilience.
|
|
914
1336
|
// The raw `#fetch` below stays unwrapped for the direct-to-storage asset PUT
|
|
@@ -916,7 +1338,7 @@ export class Aex {
|
|
|
916
1338
|
const baseFetch = resolved.fetch ?? ((input, init) => fetch(input, init));
|
|
917
1339
|
const retryingFetch = withRetry(baseFetch, resolved.retry);
|
|
918
1340
|
this.#http = new HttpClient({
|
|
919
|
-
...(
|
|
1341
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
920
1342
|
apiKey,
|
|
921
1343
|
fetch: retryingFetch,
|
|
922
1344
|
// Opt-in local diagnostics: emit a redacted per-request trace to
|
|
@@ -929,8 +1351,10 @@ export class Aex {
|
|
|
929
1351
|
this.#fetch = resolved.fetch;
|
|
930
1352
|
this.agentsMd = new AgentsMdClient(this.#http);
|
|
931
1353
|
this.files = new FilesClient(this.#http);
|
|
1354
|
+
this.skills = new SkillsClient(this.#http);
|
|
932
1355
|
this.secrets = new SecretsClient(this.#http);
|
|
933
1356
|
this.sessions = new SessionClient(this.#http, (options) => this.#buildSessionCreateRequest(options), this.#fetch);
|
|
1357
|
+
this.outputs = new OutputsClient(this.#http);
|
|
934
1358
|
}
|
|
935
1359
|
/**
|
|
936
1360
|
* Internal: satisfies the `SecretUploader` surface so a
|
|
@@ -944,8 +1368,9 @@ export class Aex {
|
|
|
944
1368
|
/**
|
|
945
1369
|
* Internal: materialize raw bytes to the content-addressable asset store
|
|
946
1370
|
* (`/assets/presign` → PUT → `/assets/finalize`). Used by the session-create
|
|
947
|
-
* prepare step to upload draft skill
|
|
948
|
-
* the wire submission carries only plain `kind:"asset"`
|
|
1371
|
+
* prepare step to upload draft skill / tool / agentsMd / file bundles so
|
|
1372
|
+
* the wire submission carries only plain `kind:"asset"` refs (skills resolve
|
|
1373
|
+
* to name-only `kind:"skill"` refs after their bytes upload).
|
|
949
1374
|
* NOT part of the public API.
|
|
950
1375
|
*/
|
|
951
1376
|
async _uploadAsset(args) {
|
|
@@ -957,6 +1382,29 @@ export class Aex {
|
|
|
957
1382
|
...(this.#fetch ? { fetch: this.#fetch } : {})
|
|
958
1383
|
});
|
|
959
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
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Internal: upsert already-uploaded skill metadata into the workspace registry.
|
|
1402
|
+
* The bytes are staged through `_uploadAsset`; this call binds the content hash
|
|
1403
|
+
* to a mutable workspace skill name before the run references that name.
|
|
1404
|
+
*/
|
|
1405
|
+
async _upsertSkill(args) {
|
|
1406
|
+
return operations.upsertSkill(this.#http, args);
|
|
1407
|
+
}
|
|
960
1408
|
/**
|
|
961
1409
|
* Convenience one-shot on top of the canonical session API:
|
|
962
1410
|
* open a session, send `message` as the first turn, stream until the session
|
|
@@ -971,61 +1419,46 @@ export class Aex {
|
|
|
971
1419
|
assertNoLegacySessionFields(options, "Aex.run");
|
|
972
1420
|
const input = normaliseSessionInput(message, "Aex.run", "message");
|
|
973
1421
|
assertNoSessionSendSignal(stream, "Aex.run stream");
|
|
974
|
-
const
|
|
1422
|
+
const sendOptions = {
|
|
975
1423
|
...(stream ?? {}),
|
|
976
1424
|
...(scopedSignal?.signal ? { signal: scopedSignal.signal } : {}),
|
|
977
1425
|
...(opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {}),
|
|
978
1426
|
...(opts.idleTimeoutMs !== undefined ? { idleTimeoutMs: opts.idleTimeoutMs } : {}),
|
|
979
|
-
...(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 } : {})
|
|
980
1430
|
};
|
|
981
1431
|
// Derive the message key from the create key (like the CLI) so a retried
|
|
982
1432
|
// run with the same `idempotencyKey` de-duplicates BOTH the create and the
|
|
983
1433
|
// billable turn server-side — never a duplicate billable run (sdk-dx-3).
|
|
984
|
-
const createKey = createOptions.idempotencyKey
|
|
985
|
-
const messageKey = messageIdempotencyKey
|
|
1434
|
+
const createKey = operations.resolveIdempotencyKey(createOptions.idempotencyKey);
|
|
1435
|
+
const messageKey = messageIdempotencyKey !== undefined ? operations.resolveIdempotencyKey(messageIdempotencyKey) : deriveMessageKey(createKey);
|
|
986
1436
|
const session = await this.sessions.create({ ...createOptions, idempotencyKey: createKey });
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
+
}
|
|
991
1454
|
const runId = turnResult.sessionId;
|
|
992
|
-
// Settle-consistent enrichment (opt-in): the park EVENT that ends the
|
|
993
|
-
// stream lands tens of seconds BEFORE the settle write that flips the
|
|
994
|
-
// record and stamps costTelemetry/costUsd, so an immediate read misses
|
|
995
|
-
// the showback on virtually every fresh run. `settleConsistent: true`
|
|
996
|
-
// polls for the parked RECORD (bounded; degrades to the immediate read).
|
|
997
|
-
const settledRecord = opts.settleConsistent === true
|
|
998
|
-
? await settledSessionRecord(this.#http, runId, turnResult.session, scopedSignal?.signal)
|
|
999
|
-
: undefined;
|
|
1000
|
-
const sessionRecord = settledRecord ?? turnResult.session;
|
|
1001
1455
|
if (deleteAfter) {
|
|
1002
1456
|
await session.delete();
|
|
1003
1457
|
}
|
|
1458
|
+
const sessionRecord = turnResult.session;
|
|
1004
1459
|
const run = sessionToRun(sessionRecord);
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
const ok = turnResult.status === "idle" || turnResult.status === "suspended";
|
|
1008
|
-
if (!ok && scopedSignal?.signal.aborted) {
|
|
1009
|
-
// The client-side wait budget (opts.timeoutMs) expired before the run
|
|
1010
|
-
// reached a terminal park. Parity with SessionHandle.wait(): THROW rather
|
|
1011
|
-
// than silently returning a misleading {ok:false,status:"running"} with no
|
|
1012
|
-
// error (pre-launch edge-sweep F3). The run continues server-side.
|
|
1013
|
-
throw new RunStateError(`Aex.run: timed out after ${opts.timeoutMs}ms waiting for run ${runId} to park (last status ` +
|
|
1014
|
-
`${JSON.stringify(turnResult.status)}); the run continues server-side — cancel via ` +
|
|
1015
|
-
`session.cancel() or resume with openSession(${JSON.stringify(runId)})`);
|
|
1016
|
-
}
|
|
1017
|
-
const trace = runTraceFromEvents(events);
|
|
1018
|
-
// Surface the trace-derived usage at the top level when the run record does
|
|
1019
|
-
// not carry its own usage (the managed plane doesn't populate session.usage);
|
|
1020
|
-
// the per-event trace still yields token counts (pre-launch edge-sweep F5).
|
|
1021
|
-
// When NEITHER source carries token counts (trace usage is `{}` — the
|
|
1022
|
-
// managed plane emits no `aex.usage` events today), leave `usage` absent so
|
|
1023
|
-
// `result.usage` honors its "when the deployment exposes it" contract
|
|
1024
|
-
// instead of surfacing a truthy-but-empty object.
|
|
1025
|
-
const recordUsage = hasUsageCounts(sessionRecord.usage) ? sessionRecord.usage : undefined;
|
|
1026
|
-
const usage = recordUsage ?? (hasUsageCounts(trace.usage) ? trace.usage : undefined);
|
|
1027
|
-
const costUsd = typeof sessionRecord.costUsd === "number" ? sessionRecord.costUsd : undefined;
|
|
1028
|
-
const errorMessage = typeof sessionRecord.errorMessage === "string" && sessionRecord.errorMessage ? sessionRecord.errorMessage : undefined;
|
|
1460
|
+
const trace = runTraceFromEvents(turnResult.events);
|
|
1461
|
+
const outcome = turnResult.outcome;
|
|
1029
1462
|
const result = {
|
|
1030
1463
|
runId,
|
|
1031
1464
|
run,
|
|
@@ -1033,17 +1466,18 @@ export class Aex {
|
|
|
1033
1466
|
session: sessionRecord,
|
|
1034
1467
|
turn: turnResult.turn,
|
|
1035
1468
|
status: turnResult.status,
|
|
1036
|
-
ok,
|
|
1469
|
+
ok: turnResult.ok,
|
|
1470
|
+
costUsd: turnResult.costUsd,
|
|
1471
|
+
usage: turnResult.usage,
|
|
1037
1472
|
text: turnResult.text,
|
|
1038
1473
|
messages: turnResult.messages,
|
|
1039
|
-
events,
|
|
1474
|
+
events: turnResult.events,
|
|
1040
1475
|
trace,
|
|
1041
|
-
outputs,
|
|
1042
|
-
...(
|
|
1043
|
-
...(
|
|
1044
|
-
...(!ok && errorMessage ? { error: errorMessage } : {})
|
|
1476
|
+
outputs: turnResult.outputs,
|
|
1477
|
+
...(turnResult.error !== undefined ? { error: turnResult.error } : {}),
|
|
1478
|
+
...(outcome !== undefined ? { outcome } : {})
|
|
1045
1479
|
};
|
|
1046
|
-
if (opts.throwOnFailure && !ok) {
|
|
1480
|
+
if (opts.throwOnFailure && !turnResult.ok) {
|
|
1047
1481
|
// A turn that failed because the upstream provider throttled us surfaces
|
|
1048
1482
|
// as a structured, non-leaky AexRateLimitError carrying the provider
|
|
1049
1483
|
// fault, so callers can branch on `isRateLimited(err)` and replay.
|
|
@@ -1057,7 +1491,7 @@ export class Aex {
|
|
|
1057
1491
|
...(throttle.retryAfterMs !== undefined ? { retryAfterMs: throttle.retryAfterMs } : {})
|
|
1058
1492
|
});
|
|
1059
1493
|
}
|
|
1060
|
-
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 });
|
|
1061
1495
|
}
|
|
1062
1496
|
return result;
|
|
1063
1497
|
}
|
|
@@ -1065,6 +1499,46 @@ export class Aex {
|
|
|
1065
1499
|
scopedSignal?.clear();
|
|
1066
1500
|
}
|
|
1067
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: _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 }, { idempotencyKey: operations.resolveIdempotencyKey(createOptions.idempotencyKey) });
|
|
1514
|
+
return { runId, session: new SessionHandle(this.#http, session, this.#fetch) };
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Run a batch of one-shot items with a bounded worker pool, returning every
|
|
1518
|
+
* item's settled result PLUS a REAL cost/usage rollup. The rollup is honest
|
|
1519
|
+
* because each `run()` awaits settle, so each item's `costUsd`/`usage` is
|
|
1520
|
+
* populated — a failed item lands in `failed[]`, never a silent `$0` success.
|
|
1521
|
+
* Concurrency is clamped below the workspace tier cap.
|
|
1522
|
+
*/
|
|
1523
|
+
async batch(items, options = {}) {
|
|
1524
|
+
if (!Array.isArray(items)) {
|
|
1525
|
+
throw new RunConfigValidationError("Aex.batch: items must be an array of run options");
|
|
1526
|
+
}
|
|
1527
|
+
const concurrency = Math.min(Math.max(1, Math.floor(options.concurrency ?? DEFAULT_BATCH_CONCURRENCY)), BATCH_MAX_CONCURRENCY);
|
|
1528
|
+
const results = (await mapWithConcurrency(items, concurrency, async (item) => {
|
|
1529
|
+
const result = await this.run(item);
|
|
1530
|
+
return {
|
|
1531
|
+
runId: result.runId,
|
|
1532
|
+
status: result.status,
|
|
1533
|
+
ok: result.ok,
|
|
1534
|
+
costUsd: result.costUsd,
|
|
1535
|
+
usage: result.usage,
|
|
1536
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
1537
|
+
...(result.outcome !== undefined ? { outcome: result.outcome } : {})
|
|
1538
|
+
};
|
|
1539
|
+
}));
|
|
1540
|
+
return rollupBatch(results);
|
|
1541
|
+
}
|
|
1068
1542
|
openSession(optionsOrId) {
|
|
1069
1543
|
return typeof optionsOrId === "string"
|
|
1070
1544
|
? this.sessions.open(optionsOrId)
|
|
@@ -1083,31 +1557,33 @@ export class Aex {
|
|
|
1083
1557
|
throw new RunConfigValidationError("Aex.openSession: message is not a supported option; sessions are created without a first " +
|
|
1084
1558
|
"message — send it with session.send(...), or use run({ message }) for a one-shot.");
|
|
1085
1559
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
throw new RunConfigValidationError(`Aex.openSession: provider ${JSON.stringify(options.provider)} is not available for ` +
|
|
1091
|
-
`model ${JSON.stringify(options.model)} (supported: ${supportedProviders.join(", ")})`);
|
|
1560
|
+
// Model is REQUIRED and checked BEFORE the provider key, so omitting `model`
|
|
1561
|
+
// reports "model is required" rather than a misleading provider-key message.
|
|
1562
|
+
if (typeof options.model !== "string" || !options.model) {
|
|
1563
|
+
throw configError("Aex.openSession", "model", "model is required");
|
|
1092
1564
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
throw new RunConfigValidationError(`Aex.openSession: model ${JSON.stringify(options.model)} is not a known model id, so its provider ` +
|
|
1105
|
-
`cannot be inferred — pass provider explicitly (with a matching apiKeys entry) to run a model ` +
|
|
1106
|
-
`this SDK version does not know about.`);
|
|
1565
|
+
// One model→provider resolver (SSoT), shared with the CLI: it honors an
|
|
1566
|
+
// explicit provider (forward-compat: an unknown model is allowed through so a
|
|
1567
|
+
// slightly-old SDK can still run a newly-launched model), infers the default
|
|
1568
|
+
// provider for a known model, and — for an UNKNOWN model with no provider —
|
|
1569
|
+
// throws a shared `did you mean?` suggestion.
|
|
1570
|
+
let provider;
|
|
1571
|
+
try {
|
|
1572
|
+
provider = resolveModelProvider(options.model, options.provider);
|
|
1573
|
+
}
|
|
1574
|
+
catch (err) {
|
|
1575
|
+
throw configError("Aex.openSession", options.provider === undefined ? "model" : "provider", err instanceof Error ? err.message : String(err), options.provider ?? options.model);
|
|
1107
1576
|
}
|
|
1108
1577
|
validateApiKeys(options.apiKeys, provider, "Aex.openSession");
|
|
1109
|
-
|
|
1110
|
-
|
|
1578
|
+
// WS9 fail-closed: `outputMode:'stream'` on a NON-streamable provider is a
|
|
1579
|
+
// hard reject at the earliest seam (no silent downgrade to buffered).
|
|
1580
|
+
if (options.outputMode !== undefined) {
|
|
1581
|
+
try {
|
|
1582
|
+
assertStreamableOutputMode(options.outputMode, provider);
|
|
1583
|
+
}
|
|
1584
|
+
catch (err) {
|
|
1585
|
+
throw configError("Aex.openSession", "outputMode", err instanceof Error ? err.message : String(err), options.outputMode);
|
|
1586
|
+
}
|
|
1111
1587
|
}
|
|
1112
1588
|
// Fast client-side validation via the contract parsers (the SSoT). runtimeSize
|
|
1113
1589
|
// and timeout are STABLE closed sets whose invalid values the create endpoint
|
|
@@ -1128,31 +1604,42 @@ export class Aex {
|
|
|
1128
1604
|
}
|
|
1129
1605
|
const { declarations: secretEnvDeclarations, values: envSecretValues } = splitSecretEnv(options.environment?.secrets);
|
|
1130
1606
|
let limits;
|
|
1607
|
+
const limitsInput = {};
|
|
1608
|
+
if (options.overrides?.maxSpendUsd !== undefined)
|
|
1609
|
+
limitsInput.maxSpendUsd = options.overrides.maxSpendUsd;
|
|
1610
|
+
if (options.overrides?.maxTurns !== undefined)
|
|
1611
|
+
limitsInput.maxTurns = options.overrides.maxTurns;
|
|
1131
1612
|
try {
|
|
1132
|
-
limits = parseRunLimits(
|
|
1133
|
-
? undefined
|
|
1134
|
-
: { maxSpendUsd: options.overrides.maxSpendUsd });
|
|
1613
|
+
limits = parseRunLimits(Object.keys(limitsInput).length > 0 ? limitsInput : undefined);
|
|
1135
1614
|
}
|
|
1136
1615
|
catch (err) {
|
|
1137
|
-
|
|
1616
|
+
// One `configError` factory for every client-side validation throw, so
|
|
1617
|
+
// maxSpendUsd/maxTurns are `RunConfigValidationError` (not a base AexError).
|
|
1618
|
+
throw configError("Aex.openSession", "limits", err instanceof Error ? err.message : String(err));
|
|
1138
1619
|
}
|
|
1139
1620
|
const uploader = (args) => this._uploadAsset(args);
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1621
|
+
const streamUploader = (args) => this._uploadAssetStream(args);
|
|
1622
|
+
// The four prepare passes touch disjoint instance sets, so run them
|
|
1623
|
+
// concurrently; each internally uploads its drafts with bounded concurrency.
|
|
1624
|
+
const [preparedTools, preparedSkills, preparedAgentsMd, preparedFiles] = await Promise.all([
|
|
1625
|
+
prepareTools(options.tools ?? [], uploader),
|
|
1626
|
+
prepareSkills(options.skills ?? [], this),
|
|
1627
|
+
prepareAgentsMd(options.agentsMd ?? [], uploader),
|
|
1628
|
+
prepareFiles(options.files ?? [], uploader, streamUploader)
|
|
1629
|
+
]);
|
|
1143
1630
|
const { submissionMcpServers, mergedMcpSecrets } = mergeMcpServers(options.mcpServers ?? [], []);
|
|
1144
1631
|
const outputCapture = outputsForWire(options.outputs);
|
|
1145
1632
|
const environment = sessionEnvironmentForWire(options.environment);
|
|
1146
1633
|
const submission = {
|
|
1147
1634
|
model: options.model,
|
|
1148
1635
|
...(options.system ? { system: options.system } : {}),
|
|
1149
|
-
// Builtin name strings + custom tool refs
|
|
1150
|
-
//
|
|
1636
|
+
// Builtin name strings + custom tool refs ride the `tools` union; skills
|
|
1637
|
+
// are first-class name refs in `submission.skills`.
|
|
1151
1638
|
tools: [
|
|
1152
1639
|
...preparedTools.builtinNames,
|
|
1153
|
-
...preparedTools.refs
|
|
1154
|
-
...preparedTools.skillToolRefs
|
|
1640
|
+
...preparedTools.refs
|
|
1155
1641
|
],
|
|
1642
|
+
...(preparedSkills.length > 0 ? { skills: preparedSkills } : {}),
|
|
1156
1643
|
agentsMd: preparedAgentsMd,
|
|
1157
1644
|
files: preparedFiles,
|
|
1158
1645
|
mcpServers: submissionMcpServers,
|
|
@@ -1163,7 +1650,9 @@ export class Aex {
|
|
|
1163
1650
|
...(options.includeBuiltinTools !== undefined
|
|
1164
1651
|
? { includeBuiltinTools: options.includeBuiltinTools }
|
|
1165
1652
|
: {}),
|
|
1166
|
-
...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {})
|
|
1653
|
+
...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {}),
|
|
1654
|
+
...(options.responseFormat !== undefined ? { responseFormat: options.responseFormat } : {}),
|
|
1655
|
+
...(options.approvalGate !== undefined ? { approvalGate: options.approvalGate } : {})
|
|
1167
1656
|
};
|
|
1168
1657
|
const secrets = {
|
|
1169
1658
|
...(options.apiKeys ? { apiKeys: options.apiKeys } : {}),
|
|
@@ -1239,6 +1728,35 @@ export class Aex {
|
|
|
1239
1728
|
return operations.getWebhookSigningSecret(this.#http);
|
|
1240
1729
|
}
|
|
1241
1730
|
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Resolve the effective `baseUrl` from a self-describing API key (ZERO-network):
|
|
1733
|
+
* - key omitted-of-shape (opaque/legacy): return the caller's `baseUrl`
|
|
1734
|
+
* unchanged (HttpClient falls back to the prd default).
|
|
1735
|
+
* - `baseUrl` omitted: DERIVE it from the key's plane; a plane with no default
|
|
1736
|
+
* host (dev) requires an explicit `baseUrl` — throw with that guidance.
|
|
1737
|
+
* - `baseUrl` supplied but its plane DISAGREES with the key's plane: throw
|
|
1738
|
+
* {@link CredentialValidationError} BEFORE any request (the exact `dev key
|
|
1739
|
+
* against the prd default → bare token_invalid` trap).
|
|
1740
|
+
*/
|
|
1741
|
+
function resolveBaseUrlForKey(apiKey, baseUrl) {
|
|
1742
|
+
const parsed = parseApiKey(apiKey);
|
|
1743
|
+
if (parsed === null)
|
|
1744
|
+
return baseUrl;
|
|
1745
|
+
const planeUrl = PLANE_BASE_URLS[parsed.plane];
|
|
1746
|
+
if (baseUrl === undefined) {
|
|
1747
|
+
if (planeUrl === null) {
|
|
1748
|
+
throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane, which has no default host — pass baseUrl explicitly.`, { plane: parsed.plane });
|
|
1749
|
+
}
|
|
1750
|
+
return planeUrl;
|
|
1751
|
+
}
|
|
1752
|
+
// The only client-detectable mismatch: a non-prd key pointed at the canonical
|
|
1753
|
+
// prd host (dev has no canonical host to compare a prd key against).
|
|
1754
|
+
if (baseUrl === PLANE_BASE_URLS.prd && parsed.plane !== "prd") {
|
|
1755
|
+
throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane but baseUrl targets the prd plane (${PLANE_BASE_URLS.prd}) — ` +
|
|
1756
|
+
`pass the ${parsed.plane} plane baseUrl, or omit baseUrl to auto-route.`, { plane: parsed.plane, baseUrl });
|
|
1757
|
+
}
|
|
1758
|
+
return baseUrl;
|
|
1759
|
+
}
|
|
1242
1760
|
// `Run.status` is a loose `string` on the wire shape, so we membership-test
|
|
1243
1761
|
// against the canonical terminal set rather than re-deriving one (which is how
|
|
1244
1762
|
// `timed_out` got dropped from the old hardcoded list).
|
|
@@ -1251,21 +1769,33 @@ const SETTLE_POLL_DEADLINE_MS = 60_000;
|
|
|
1251
1769
|
/** Interval between settle-poll reads (ms). */
|
|
1252
1770
|
const SETTLE_POLL_INTERVAL_MS = 750;
|
|
1253
1771
|
/**
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1772
|
+
* A settle marker on a record: the settle commit stamps `settledAt` (and always
|
|
1773
|
+
* `costUsd`, default 0) plus the terminal outcome / cost telemetry. The SAME
|
|
1774
|
+
* predicate gates the early-return and the loop-return so a $0 turn (costUsd:0
|
|
1775
|
+
* present) resolves as settled — never a hang, never an undefined cost.
|
|
1776
|
+
*/
|
|
1777
|
+
function isSettledRecord(record) {
|
|
1778
|
+
if (!isSessionParked(record.status))
|
|
1779
|
+
return false;
|
|
1780
|
+
return (record.settledAt !== undefined ||
|
|
1781
|
+
typeof record.costUsd === "number" ||
|
|
1782
|
+
record.lastTurnOutcome !== undefined ||
|
|
1783
|
+
providerUsageOf(record) !== undefined);
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Poll for the session RECORD to reach a SETTLED state — i.e. for the settle
|
|
1787
|
+
* write (which stamps `settledAt` + `costUsd` + the terminal outcome) to land.
|
|
1788
|
+
* Returns the settled record, or `undefined` on timeout/abort/read-failure so
|
|
1789
|
+
* the caller can fall back to the record it already holds. The first read is
|
|
1258
1790
|
* immediate, so a fast settle costs one extra GET and no added latency.
|
|
1259
1791
|
*/
|
|
1260
1792
|
async function settledSessionRecord(http, sessionId, lastSeen, signal) {
|
|
1261
|
-
|
|
1262
|
-
// only trust it when it is parked AND already carries the settle-stamped cost.
|
|
1263
|
-
if (isSessionParked(lastSeen.status) && typeof lastSeen.costUsd === "number")
|
|
1793
|
+
if (isSettledRecord(lastSeen))
|
|
1264
1794
|
return lastSeen;
|
|
1265
1795
|
const deadline = Date.now() + SETTLE_POLL_DEADLINE_MS;
|
|
1266
1796
|
while (signal?.aborted !== true && Date.now() < deadline) {
|
|
1267
1797
|
const record = await operations.getSession(http, sessionId).catch(() => undefined);
|
|
1268
|
-
if (record !== undefined &&
|
|
1798
|
+
if (record !== undefined && isSettledRecord(record))
|
|
1269
1799
|
return record;
|
|
1270
1800
|
try {
|
|
1271
1801
|
await sleep(SETTLE_POLL_INTERVAL_MS, signal);
|
|
@@ -1276,6 +1806,28 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
|
|
|
1276
1806
|
}
|
|
1277
1807
|
return undefined;
|
|
1278
1808
|
}
|
|
1809
|
+
/**
|
|
1810
|
+
* How long a follow-up `send()` waits for the session RECORD to catch up after
|
|
1811
|
+
* our own turn parked idle before treating the `session_busy` as a genuinely
|
|
1812
|
+
* in-flight turn (ms). The idle park EVENT ends the turn stream, but the record
|
|
1813
|
+
* commit lags briefly behind it.
|
|
1814
|
+
*/
|
|
1815
|
+
const SESSION_BUSY_RECONCILE_DEADLINE_MS = 30_000;
|
|
1816
|
+
/** Interval between record reads while reconciling a settle-lag `session_busy` (ms). */
|
|
1817
|
+
const SESSION_BUSY_RECONCILE_INTERVAL_MS = 500;
|
|
1818
|
+
/**
|
|
1819
|
+
* A 409 `session_busy` whose CURRENT status is `running` — the one case that is
|
|
1820
|
+
* transient: the platform is still catching up from our own just-parked turn.
|
|
1821
|
+
* The 409 body carries `{ error:"session_busy", status:<current> }`; any other
|
|
1822
|
+
* busy status (suspended / cancelling / deleted) is a real, non-retryable
|
|
1823
|
+
* rejection that must surface immediately.
|
|
1824
|
+
*/
|
|
1825
|
+
function isSettlingSessionBusy(err) {
|
|
1826
|
+
if (!(err instanceof AexApiError) || err.status !== 409)
|
|
1827
|
+
return false;
|
|
1828
|
+
const body = asRecord(err.body);
|
|
1829
|
+
return body.error === "session_busy" && body.status === "running";
|
|
1830
|
+
}
|
|
1279
1831
|
/**
|
|
1280
1832
|
* A session is "parked" once it stops making progress: it reached one of the
|
|
1281
1833
|
* turn-terminal statuses (`idle` / `suspended` / `error`) or a terminal run
|
|
@@ -1284,6 +1836,7 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
|
|
|
1284
1836
|
function isSessionParked(status) {
|
|
1285
1837
|
return (status === "idle" ||
|
|
1286
1838
|
status === "suspended" ||
|
|
1839
|
+
status === "awaiting_approval" ||
|
|
1287
1840
|
status === "error" ||
|
|
1288
1841
|
status === "deleted" ||
|
|
1289
1842
|
status === "expired" ||
|
|
@@ -1314,49 +1867,6 @@ function scopedAbortSignal(timeoutMs) {
|
|
|
1314
1867
|
}
|
|
1315
1868
|
};
|
|
1316
1869
|
}
|
|
1317
|
-
/** Escape a literal string for safe interpolation into a RegExp. */
|
|
1318
|
-
function escapeRegExp(input) {
|
|
1319
|
-
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1320
|
-
}
|
|
1321
|
-
function isOutputPathSelector(selector) {
|
|
1322
|
-
return Boolean(selector && typeof selector === "object" && "path" in selector);
|
|
1323
|
-
}
|
|
1324
|
-
function resolveOutputFileSelector(outputs, selector, runId) {
|
|
1325
|
-
if (isOutputPathSelector(selector)) {
|
|
1326
|
-
const target = normalizeOutputLookupPath(selector.path);
|
|
1327
|
-
if (!target) {
|
|
1328
|
-
throw new RunStateError("Aex.downloadOutput: output path must be non-empty", {
|
|
1329
|
-
runId,
|
|
1330
|
-
path: selector.path
|
|
1331
|
-
});
|
|
1332
|
-
}
|
|
1333
|
-
const matches = outputs.filter((output) => {
|
|
1334
|
-
if (typeof output.filename !== "string")
|
|
1335
|
-
return false;
|
|
1336
|
-
const filename = normalizeOutputLookupPath(output.filename);
|
|
1337
|
-
if (selector.match === "suffix") {
|
|
1338
|
-
return filename === target || filename.endsWith(`/${target}`);
|
|
1339
|
-
}
|
|
1340
|
-
return filename === target;
|
|
1341
|
-
});
|
|
1342
|
-
if (matches.length === 1)
|
|
1343
|
-
return matches[0];
|
|
1344
|
-
if (matches.length > 1) {
|
|
1345
|
-
throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" matched multiple files`, { runId, path: selector.path, matches: matches.map((output) => output.filename ?? output.id) });
|
|
1346
|
-
}
|
|
1347
|
-
throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" was not found`, {
|
|
1348
|
-
runId,
|
|
1349
|
-
path: selector.path
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
if (typeof selector.id !== "string" || selector.id.length === 0) {
|
|
1353
|
-
throw new RunStateError("Aex.downloadOutput: selector must include an output id or path", { runId });
|
|
1354
|
-
}
|
|
1355
|
-
return { ...selector, id: selector.id };
|
|
1356
|
-
}
|
|
1357
|
-
function normalizeOutputLookupPath(path) {
|
|
1358
|
-
return path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1359
|
-
}
|
|
1360
1870
|
async function writeOptionalFile(bytes, to) {
|
|
1361
1871
|
if (to !== undefined) {
|
|
1362
1872
|
const { writeFile } = await import("node:fs/promises");
|
|
@@ -1381,11 +1891,13 @@ function sleep(ms, signal) {
|
|
|
1381
1891
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1382
1892
|
});
|
|
1383
1893
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1894
|
+
/**
|
|
1895
|
+
* ONE factory for every client-side validation throw: a
|
|
1896
|
+
* {@link RunConfigValidationError} carrying structured `details: { field, value? }`
|
|
1897
|
+
* so callers branch on `err.details.field` instead of string-parsing the message.
|
|
1898
|
+
*/
|
|
1899
|
+
function configError(surface, field, message, value) {
|
|
1900
|
+
return new RunConfigValidationError(`${surface}: ${message}`, value === undefined ? { field } : { field, value });
|
|
1389
1901
|
}
|
|
1390
1902
|
/**
|
|
1391
1903
|
* Derive the message idempotency key from the session-create key. Mirrors the
|
|
@@ -1440,7 +1952,7 @@ function normaliseSessionInput(input, surface, field) {
|
|
|
1440
1952
|
}
|
|
1441
1953
|
function assertNoLegacySessionFields(options, surface) {
|
|
1442
1954
|
const record = options;
|
|
1443
|
-
const removedProxyField = "proxy"
|
|
1955
|
+
const removedProxyField = ["proxy", "Endpoints"].join("");
|
|
1444
1956
|
const messages = {
|
|
1445
1957
|
input: "send user messages with session.send(...) or use run({ message }).",
|
|
1446
1958
|
prompt: "use message for one-shot run input or session.send(...) for follow-up messages.",
|
|
@@ -1449,12 +1961,11 @@ function assertNoLegacySessionFields(options, surface) {
|
|
|
1449
1961
|
idleTtl: "use overrides.idleTtl.",
|
|
1450
1962
|
retention: "use overrides.idleTtl.",
|
|
1451
1963
|
secretEnv: "use environment.secrets.",
|
|
1452
|
-
skills: "skills are now tools; build one with Tools.fromSkillDir/fromSkillUrl and pass it in tools.",
|
|
1453
1964
|
secrets: "use top-level apiKeys for provider keys and environment.secrets for run secrets.",
|
|
1454
1965
|
runtimeSize: "use runtime.",
|
|
1455
|
-
parentRunId: "subagents are session-internal; parentRunId is not part of the session API.",
|
|
1456
1966
|
limits: "use overrides.",
|
|
1457
1967
|
timeout: "use overrides.timeout.",
|
|
1968
|
+
parentRunId: "subagent lineage is assigned by the platform.",
|
|
1458
1969
|
signal: "use session.cancel() / session.suspend() for remote control.",
|
|
1459
1970
|
postHook: "send a follow-up validation message when the session returns idle.",
|
|
1460
1971
|
[removedProxyField]: "proxy endpoints are not part of the public SDK session API."
|
|
@@ -1541,50 +2052,52 @@ async function resolveAssetId(entry, bundle, uploader) {
|
|
|
1541
2052
|
entry._rememberAsset(uploaded.assetId);
|
|
1542
2053
|
return uploaded.assetId;
|
|
1543
2054
|
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Max concurrent asset uploads within a single prepare pass. Bounded so a large
|
|
2057
|
+
* tools/files/skills list can't stampede the presign endpoint (the transport
|
|
2058
|
+
* already carries bounded retry); 5 is a comfortable overlap without a burst.
|
|
2059
|
+
*/
|
|
2060
|
+
const UPLOAD_CONCURRENCY = 5;
|
|
2061
|
+
/**
|
|
2062
|
+
* Map `items` through `fn` with at most `limit` in flight, PRESERVING ORDER (the
|
|
2063
|
+
* result at index `i` is `fn(items[i], i)` regardless of completion order). A
|
|
2064
|
+
* rejection from any call propagates (the first to reject wins) once the
|
|
2065
|
+
* in-flight batch settles. Exported for direct unit testing; not part of the
|
|
2066
|
+
* public SDK surface (index.ts controls that).
|
|
2067
|
+
*/
|
|
2068
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
2069
|
+
const out = new Array(items.length);
|
|
2070
|
+
let next = 0;
|
|
2071
|
+
const lanes = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
|
2072
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
2073
|
+
out[i] = await fn(items[i], i);
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
await Promise.all(lanes);
|
|
2077
|
+
return out;
|
|
2078
|
+
}
|
|
1544
2079
|
/**
|
|
1545
2080
|
* Split the `tools` union into custom tool refs (drafts eagerly uploaded as
|
|
1546
|
-
* assets)
|
|
1547
|
-
*
|
|
1548
|
-
*
|
|
1549
|
-
* the three groups are recombined on the wire by the caller (the BFF parser
|
|
1550
|
-
* splits them back apart by kind).
|
|
2081
|
+
* assets) and builtin tool-name references (bare strings, validated against the
|
|
2082
|
+
* closed {@link BUILTIN_TOOL_NAMES} set). Builtin names are deduped, in input
|
|
2083
|
+
* order.
|
|
1551
2084
|
*/
|
|
1552
2085
|
async function prepareTools(tools, uploader) {
|
|
1553
|
-
const
|
|
1554
|
-
const skillToolRefs = [];
|
|
1555
|
-
const seenBuiltins = new Set();
|
|
1556
|
-
const builtinNames = [];
|
|
1557
|
-
for (let i = 0; i < tools.length; i++) {
|
|
1558
|
-
const entry = tools[i];
|
|
2086
|
+
const prepared = await mapWithConcurrency(tools, UPLOAD_CONCURRENCY, async (entry, i) => {
|
|
1559
2087
|
// A bare string is a builtin tool reference.
|
|
1560
2088
|
if (typeof entry === "string") {
|
|
1561
2089
|
if (!BUILTIN_TOOL_NAMES.includes(entry)) {
|
|
1562
2090
|
throw new RunConfigValidationError(`aex: tools[${i}] (${JSON.stringify(entry)}) is not a builtin tool name; ` +
|
|
1563
|
-
`expected a Tool
|
|
1564
|
-
}
|
|
1565
|
-
if (!seenBuiltins.has(entry)) {
|
|
1566
|
-
seenBuiltins.add(entry);
|
|
1567
|
-
builtinNames.push(entry);
|
|
1568
|
-
}
|
|
1569
|
-
continue;
|
|
1570
|
-
}
|
|
1571
|
-
// A skill-tool: upload its bundle (if a draft) and emit a `kind:"skill"` ref.
|
|
1572
|
-
if (entry instanceof SkillTool) {
|
|
1573
|
-
const ref = entry.ref;
|
|
1574
|
-
if (ref.kind === "draft") {
|
|
1575
|
-
const bundle = entry._takeDraftBundle();
|
|
1576
|
-
if (!bundle) {
|
|
1577
|
-
throw new RunConfigValidationError(`aex: tools[${i}] is a draft skill-tool but has no bytes`);
|
|
1578
|
-
}
|
|
1579
|
-
const assetId = await resolveAssetId(entry, bundle, uploader);
|
|
1580
|
-
skillToolRefs.push({ kind: "skill", assetId, name: bundle.name, description: bundle.description });
|
|
1581
|
-
continue;
|
|
2091
|
+
`expected a Tool or one of: ${BUILTIN_TOOL_NAMES.join(", ")}`);
|
|
1582
2092
|
}
|
|
1583
|
-
|
|
1584
|
-
continue;
|
|
2093
|
+
return { kind: "builtin", name: entry };
|
|
1585
2094
|
}
|
|
1586
2095
|
if (!(entry instanceof Tool)) {
|
|
1587
|
-
|
|
2096
|
+
const maybeEntry = entry;
|
|
2097
|
+
if (maybeEntry instanceof Skill) {
|
|
2098
|
+
throw new RunConfigValidationError(`aex: tools[${i}] is a Skill; pass skills via the top-level skills option`);
|
|
2099
|
+
}
|
|
2100
|
+
throw new RunConfigValidationError(`aex: tools[${i}] must be a Tool or a builtin tool name`);
|
|
1588
2101
|
}
|
|
1589
2102
|
const ref = entry.ref;
|
|
1590
2103
|
if (ref.kind === "draft") {
|
|
@@ -1593,18 +2106,59 @@ async function prepareTools(tools, uploader) {
|
|
|
1593
2106
|
throw new RunConfigValidationError(`aex: tools[${i}] is draft but has no bytes`);
|
|
1594
2107
|
}
|
|
1595
2108
|
const assetId = await resolveAssetId(entry, bundle, uploader);
|
|
1596
|
-
|
|
1597
|
-
|
|
2109
|
+
return { kind: "ref", ref: { ...bundle.ref, assetId } };
|
|
2110
|
+
}
|
|
2111
|
+
return { kind: "ref", ref };
|
|
2112
|
+
});
|
|
2113
|
+
const refs = [];
|
|
2114
|
+
const seenBuiltins = new Set();
|
|
2115
|
+
const builtinNames = [];
|
|
2116
|
+
for (const item of prepared) {
|
|
2117
|
+
if (item.kind === "builtin") {
|
|
2118
|
+
if (!seenBuiltins.has(item.name)) {
|
|
2119
|
+
seenBuiltins.add(item.name);
|
|
2120
|
+
builtinNames.push(item.name);
|
|
2121
|
+
}
|
|
1598
2122
|
}
|
|
1599
|
-
|
|
2123
|
+
else {
|
|
2124
|
+
refs.push(item.ref);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return { refs, builtinNames };
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Upload/upsert workspace skills and return public name-only refs. Draft skills
|
|
2131
|
+
* auto-upsert (bytes → asset store, then registry PUT) by name; already-uploaded
|
|
2132
|
+
* skills pass through. Duplicate names are pre-checked (uploads run in parallel,
|
|
2133
|
+
* so the dedup cannot be a running set inside the map).
|
|
2134
|
+
*/
|
|
2135
|
+
async function prepareSkills(skills, uploader) {
|
|
2136
|
+
if (skills.length > SKILLS_MAX) {
|
|
2137
|
+
throw new RunConfigValidationError(`aex: skills exceeds the ${SKILLS_MAX}-skill limit (got ${skills.length})`);
|
|
2138
|
+
}
|
|
2139
|
+
const seen = new Set();
|
|
2140
|
+
for (let i = 0; i < skills.length; i++) {
|
|
2141
|
+
const entry = skills[i];
|
|
2142
|
+
if (!(entry instanceof Skill)) {
|
|
2143
|
+
throw new RunConfigValidationError(`aex: skills[${i}] must be a Skill (Skill.fromDir / fromUrl / fromFiles / fromContent / fromBytes)`);
|
|
2144
|
+
}
|
|
2145
|
+
if (seen.has(entry.name)) {
|
|
2146
|
+
throw new RunConfigValidationError(`aex: skills duplicate name: ${entry.name}`);
|
|
2147
|
+
}
|
|
2148
|
+
seen.add(entry.name);
|
|
1600
2149
|
}
|
|
1601
|
-
return
|
|
2150
|
+
return mapWithConcurrency(skills, UPLOAD_CONCURRENCY, async (entry, i) => {
|
|
2151
|
+
const uploaded = entry.isDraft ? await entry.upload(uploader) : entry;
|
|
2152
|
+
const ref = uploaded.ref;
|
|
2153
|
+
if (ref.kind !== "skill") {
|
|
2154
|
+
throw new RunConfigValidationError(`aex: skills[${i}] did not resolve to a workspace skill ref`);
|
|
2155
|
+
}
|
|
2156
|
+
return ref;
|
|
2157
|
+
});
|
|
1602
2158
|
}
|
|
1603
|
-
/** Walk AgentsMd[], eagerly upload drafts as assets, and return plain asset refs. */
|
|
2159
|
+
/** Walk AgentsMd[], eagerly upload drafts as assets (bounded concurrency), and return plain asset refs. */
|
|
1604
2160
|
async function prepareAgentsMd(agentsMds, uploader) {
|
|
1605
|
-
|
|
1606
|
-
for (let i = 0; i < agentsMds.length; i++) {
|
|
1607
|
-
const entry = agentsMds[i];
|
|
2161
|
+
return mapWithConcurrency(agentsMds, UPLOAD_CONCURRENCY, async (entry, i) => {
|
|
1608
2162
|
if (!(entry instanceof AgentsMd)) {
|
|
1609
2163
|
throw new RunConfigValidationError(`aex: agentsMd[${i}] must be an AgentsMd instance`);
|
|
1610
2164
|
}
|
|
@@ -1615,43 +2169,39 @@ async function prepareAgentsMd(agentsMds, uploader) {
|
|
|
1615
2169
|
throw new RunConfigValidationError(`aex: agentsMd[${i}] is draft but has no bytes`);
|
|
1616
2170
|
}
|
|
1617
2171
|
const assetId = await resolveAssetId(entry, bundle, uploader);
|
|
1618
|
-
|
|
1619
|
-
kind: "asset",
|
|
1620
|
-
assetId,
|
|
1621
|
-
name: bundle.name
|
|
1622
|
-
});
|
|
1623
|
-
continue;
|
|
2172
|
+
return { kind: "asset", assetId, name: bundle.name };
|
|
1624
2173
|
}
|
|
1625
|
-
|
|
1626
|
-
}
|
|
1627
|
-
return refs;
|
|
2174
|
+
return ref;
|
|
2175
|
+
});
|
|
1628
2176
|
}
|
|
1629
|
-
/** Walk File[], eagerly upload drafts as assets, and return plain asset refs. */
|
|
1630
|
-
async function prepareFiles(files, uploader) {
|
|
1631
|
-
|
|
1632
|
-
for (let i = 0; i < files.length; i++) {
|
|
1633
|
-
const entry = files[i];
|
|
2177
|
+
/** Walk File[], eagerly upload drafts as assets (bounded concurrency), and return plain asset refs. */
|
|
2178
|
+
async function prepareFiles(files, uploader, streamUploader) {
|
|
2179
|
+
return mapWithConcurrency(files, UPLOAD_CONCURRENCY, async (entry, i) => {
|
|
1634
2180
|
if (!(entry instanceof File)) {
|
|
1635
2181
|
throw new RunConfigValidationError(`aex: files[${i}] must be a File instance`);
|
|
1636
2182
|
}
|
|
1637
2183
|
const ref = entry.ref;
|
|
1638
2184
|
if (ref.kind === "draft") {
|
|
2185
|
+
// A large draft carries a streaming driver instead of in-memory bytes.
|
|
2186
|
+
const stream = entry._takeDraftStream();
|
|
2187
|
+
if (stream) {
|
|
2188
|
+
const cached = entry._cachedAssetId;
|
|
2189
|
+
if (cached !== undefined) {
|
|
2190
|
+
return { kind: "asset", assetId: cached, name: stream.name, mountPath: stream.mountPath };
|
|
2191
|
+
}
|
|
2192
|
+
const uploaded = await streamUploader({ drive: stream.drive, contentType: "application/zip" });
|
|
2193
|
+
entry._rememberAsset(uploaded.assetId);
|
|
2194
|
+
return { kind: "asset", assetId: uploaded.assetId, name: stream.name, mountPath: stream.mountPath };
|
|
2195
|
+
}
|
|
1639
2196
|
const bundle = entry._takeDraftBundle();
|
|
1640
2197
|
if (!bundle) {
|
|
1641
2198
|
throw new RunConfigValidationError(`aex: files[${i}] is draft but has no bytes`);
|
|
1642
2199
|
}
|
|
1643
2200
|
const assetId = await resolveAssetId(entry, bundle, uploader);
|
|
1644
|
-
|
|
1645
|
-
kind: "asset",
|
|
1646
|
-
assetId,
|
|
1647
|
-
name: bundle.name,
|
|
1648
|
-
mountPath: bundle.mountPath
|
|
1649
|
-
});
|
|
1650
|
-
continue;
|
|
2201
|
+
return { kind: "asset", assetId, name: bundle.name, mountPath: bundle.mountPath };
|
|
1651
2202
|
}
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1654
|
-
return refs;
|
|
2203
|
+
return ref;
|
|
2204
|
+
});
|
|
1655
2205
|
}
|
|
1656
2206
|
function mergeMcpServers(inputs, explicitSecrets) {
|
|
1657
2207
|
const submissionMcpServers = [];
|