libfx 0.0.14-dev.1534.g6afa7c33dac5 → 0.0.14
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 +49 -4
- package/chatgpt.js +2 -1
- package/durable/local.mjs +12 -12
- package/durable/vercel.mjs +1 -1
- package/durable/world.mjs +1 -1
- package/durable.js +229 -68
- package/fx-core.wasm +0 -0
- package/fx-sdk.js +82 -7
- package/fx-term.wasm +0 -0
- package/libfx.darwin-arm64.node +0 -0
- package/libfx.darwin-x64.node +0 -0
- package/libfx.linux-arm64.node +0 -0
- package/libfx.linux-x64.node +0 -0
- package/mcp.js +11 -2
- package/node.cjs +396 -48
- package/node.js +2 -0
- package/package.json +1 -1
- package/subscription.js +2 -0
- package/tool-observation.js +185 -0
- package/types/browser.d.ts +7 -0
- package/types/durable-world.d.ts +7 -1
- package/types/libfx.d.cts +129 -3
- package/types/mcp.d.ts +6 -1
- package/types/node.d.cts +7 -0
- package/types/wasm.d.ts +7 -0
package/durable.js
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
// the input to the log, takes the lease, and runs every turn the log holds
|
|
21
21
|
// until none is left, or its time runs out: then it asks the queue to
|
|
22
22
|
// deliver the same message again, and the next worker continues the turn.
|
|
23
|
+
//
|
|
24
|
+
// TLA+ models: `tests/tla/sdk/DurableSession.tla` (the log, leases and
|
|
25
|
+
// takeovers), `tests/tla/sdk/AgentRouting.tla` (agent names and routing),
|
|
26
|
+
// and `tests/tla/sdk/DurableSteer.tla` (where each message a user sends is
|
|
27
|
+
// placed). The drivers in `tests/tla/drivers/` run this code along them.
|
|
23
28
|
|
|
24
29
|
// A write the session's chain refused because another worker continued it.
|
|
25
30
|
// Harnesses recognize it by its code.
|
|
@@ -338,7 +343,14 @@ export function foldSessionLog(entries, { now = 0, alive = () => null } = {}) {
|
|
|
338
343
|
// landed ended. Its id is kept like a turn's, so a late delivery of the
|
|
339
344
|
// same message is not taken as a new cancel.
|
|
340
345
|
for (const input of inputs) {
|
|
341
|
-
if (
|
|
346
|
+
if (consumed.has(input.messageId)) continue;
|
|
347
|
+
// A resume only asks that its session be woken: it is spent once
|
|
348
|
+
// stored, and its id is kept so its deliveries are known.
|
|
349
|
+
if (input.type === "resume") {
|
|
350
|
+
consume(input.messageId);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (input.type !== "cancel") continue;
|
|
342
354
|
const live = typeof input.target === "string"
|
|
343
355
|
? !consumed.has(input.target) || openTurn?.id === input.target
|
|
344
356
|
: openTurn !== null && input.cursor > openTurn.at;
|
|
@@ -659,6 +671,10 @@ function noAgentNamed(name) {
|
|
|
659
671
|
// copy of this module in the process.
|
|
660
672
|
const agentsByName = (globalThis[Symbol.for("libfx.durableAgents")] ??= new Map());
|
|
661
673
|
|
|
674
|
+
// The backend a member opens, or null when it cannot open, as an agent
|
|
675
|
+
// libfx refused to create cannot.
|
|
676
|
+
const openMember = (member) => member.open().catch(() => null);
|
|
677
|
+
|
|
662
678
|
// The World backend of an agent named `name` in this process whose
|
|
663
679
|
// durability `matches`, the agent's copies in turn, or null.
|
|
664
680
|
async function agentNamed(name, matches) {
|
|
@@ -669,8 +685,8 @@ async function agentNamed(name, matches) {
|
|
|
669
685
|
for (let index = 0; index < members.length; index += 1) {
|
|
670
686
|
const member = members[(first + index) % members.length];
|
|
671
687
|
if (!matches(await member.durability())) continue;
|
|
672
|
-
const created = await member
|
|
673
|
-
if (typeof created
|
|
688
|
+
const created = await openMember(member);
|
|
689
|
+
if (typeof created?.deliverHere === "function") return created;
|
|
674
690
|
}
|
|
675
691
|
return null;
|
|
676
692
|
}
|
|
@@ -679,30 +695,81 @@ async function agentNamed(name, matches) {
|
|
|
679
695
|
// one on another durability can hold other sessions.
|
|
680
696
|
const agentSharing = (name, durability) => agentNamed(name, (other) => other === durability);
|
|
681
697
|
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
698
|
+
// The store a World keeps its sessions in, as its queue messages name it:
|
|
699
|
+
// its `store`, which is its name unless the app named the store.
|
|
700
|
+
const worldStore = (durability) => durability?.store ?? durability?.name;
|
|
701
|
+
|
|
702
|
+
// The store a durability keeps its sessions in, as libfx tells stores apart:
|
|
703
|
+
// a memory() is its own, and a World's is its worldStore(). Two agents of one
|
|
704
|
+
// name on one store are one agent.
|
|
705
|
+
const storeOf = (durability) => (typeof durability?.create === "function" ? durability : `world:${worldStore(durability)}`);
|
|
706
|
+
|
|
707
|
+
// A route takes its deployment's deliveries for every World in the process,
|
|
708
|
+
// so a delivery goes to the agent its message names on the store it names,
|
|
709
|
+
// and never to one of that name on another store
|
|
710
|
+
// (`tests/tla/sdk/TenantIsolation.tla` RunsInSenderStore).
|
|
711
|
+
const agentOnStore = (name, store) => agentNamed(name, (other) => typeof other.world === "function" && other.create === undefined && worldStore(other) === store);
|
|
688
712
|
|
|
689
713
|
/**
|
|
690
714
|
* The request handler a deployment mounts for queue deliveries, for every
|
|
691
715
|
* durable agent in the process: each delivery goes to the agent its message
|
|
692
|
-
* names. One route and one queue trigger serve any
|
|
716
|
+
* names, on the store it names. One route and one queue trigger serve any
|
|
717
|
+
* number of agents.
|
|
693
718
|
*/
|
|
694
719
|
export function wakeHandler() {
|
|
695
720
|
return async (request) => {
|
|
696
721
|
for (const group of agentsByName.values()) {
|
|
697
722
|
for (const member of group.members) {
|
|
698
|
-
const created = await member
|
|
699
|
-
if (created
|
|
723
|
+
const created = await openMember(member);
|
|
724
|
+
if (created?.viaRoute === true) return created.queueHandler(request);
|
|
700
725
|
}
|
|
701
726
|
}
|
|
702
727
|
return new Response("no libfx agent in this process takes queue deliveries from a route", { status: 503 });
|
|
703
728
|
};
|
|
704
729
|
}
|
|
705
730
|
|
|
731
|
+
const nameInUse = (name) => Object.assign(
|
|
732
|
+
new Error(`an agent named ${JSON.stringify(name)} with another configuration already runs on this store in this process; give each configuration its own name, or each store its own \`store\``),
|
|
733
|
+
{ code: "FX_AGENT_NAME_IN_USE", agent: name },
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// Whether two agents' options are one configuration: the same values, and
|
|
737
|
+
// the same functions and objects other than plain ones. `onEvent` only
|
|
738
|
+
// watches the agent, so it is left out.
|
|
739
|
+
function definitionOf(options) {
|
|
740
|
+
const { onEvent: _onEvent, ...definition } = options;
|
|
741
|
+
return definition;
|
|
742
|
+
}
|
|
743
|
+
function sameDefinition(a, b, depth = 0) {
|
|
744
|
+
if (Object.is(a, b)) return true;
|
|
745
|
+
if (depth > 32 || a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
746
|
+
if (ArrayBuffer.isView(a) || ArrayBuffer.isView(b)) {
|
|
747
|
+
if (!ArrayBuffer.isView(a) || !ArrayBuffer.isView(b) || a.byteLength !== b.byteLength) return false;
|
|
748
|
+
const left = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
|
749
|
+
const right = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
|
|
750
|
+
for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false;
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
const plain = (value) => Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
|
|
754
|
+
if (!plain(a) || !plain(b) || Array.isArray(a) !== Array.isArray(b)) return false;
|
|
755
|
+
const keys = Object.keys(a);
|
|
756
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
757
|
+
return keys.every((key) => Object.hasOwn(b, key) && sameDefinition(a[key], b[key], depth + 1));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Refuses `member` when another agent of `name` on its store has another
|
|
761
|
+
// configuration: deliveries for a name on a store go to any of its agents,
|
|
762
|
+
// so they must be one agent (`tests/tla/sdk/TenantIsolation.tla`
|
|
763
|
+
// RunsOnSenderAgent). Agents created without a durability share their
|
|
764
|
+
// factory's default, so they are on one store even before one of them has
|
|
765
|
+
// learned which store that is.
|
|
766
|
+
const sameStore = (a, b) => a.store === b.store || (a.defaults !== null && a.defaults === b.defaults);
|
|
767
|
+
function claimName(name, member) {
|
|
768
|
+
for (const other of agentsByName.get(name)?.members ?? []) {
|
|
769
|
+
if (other !== member && sameStore(other, member) && !sameDefinition(other.definition, member.definition)) throw nameInUse(name);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
706
773
|
// libfx starts and closes a World it `owned`, having created it; a World the
|
|
707
774
|
// app passed in stays the app's.
|
|
708
775
|
function createWorldBackend(world, durability) {
|
|
@@ -738,16 +805,19 @@ function createWorldBackend(world, durability) {
|
|
|
738
805
|
if (result?.redeliver === true) throw new Error("libfx asks the queue to deliver this message again");
|
|
739
806
|
return Number.isFinite(result?.timeoutSeconds) ? { timeoutSeconds: result.timeoutSeconds } : undefined;
|
|
740
807
|
};
|
|
808
|
+
const store = worldStore(durability);
|
|
741
809
|
// The route a deployment mounts delivers to the agent in this process the
|
|
742
|
-
// message names, whichever agent's route it is.
|
|
810
|
+
// message names, on the store it names, whichever agent's route it is. A
|
|
811
|
+
// message from before stores were named is for this World's store.
|
|
743
812
|
const queueHandler = world.createQueueHandler(queuePrefix, async (message, meta) => {
|
|
744
813
|
const named = agentOf(message?.input);
|
|
745
|
-
const agent = await
|
|
814
|
+
const agent = await agentOnStore(named, typeof message?.input?.store === "string" ? message.input.store : store);
|
|
746
815
|
if (!agent) throw noAgentNamed(named);
|
|
747
816
|
return agent.deliverHere(message, meta);
|
|
748
817
|
});
|
|
749
818
|
const backend = {
|
|
750
819
|
name,
|
|
820
|
+
store,
|
|
751
821
|
livenessKnown,
|
|
752
822
|
queueLeases,
|
|
753
823
|
// A prompt runs in the request that took it, held by its backstop's
|
|
@@ -1129,10 +1199,59 @@ const errorSummary = (error) => ({ name: error?.name ?? "Error", message: String
|
|
|
1129
1199
|
// The harness contract: a fenced write rejects with this code.
|
|
1130
1200
|
const isFenced = (error) => error?.code === "FX_FENCED";
|
|
1131
1201
|
|
|
1132
|
-
//
|
|
1202
|
+
// The kinds of message a session's queue carries.
|
|
1203
|
+
const messageTypes = new Set(["prompt", "steer", "cancel", "resume"]);
|
|
1204
|
+
|
|
1205
|
+
// What the queue carries for a message: only which input of which session
|
|
1206
|
+
// it names, for which agent, on which store. A delivery can arrive at a
|
|
1207
|
+
// public route, so its body may come from anyone: a worker acts only on a
|
|
1208
|
+
// message its session's log already holds, and runs only what that log
|
|
1209
|
+
// says (`tests/tla/sdk/TenantIsolation.tla` OnlySentRuns).
|
|
1210
|
+
function queuedMessage(message, store) {
|
|
1211
|
+
return {
|
|
1212
|
+
type: message.type,
|
|
1213
|
+
messageId: message.messageId,
|
|
1214
|
+
sessionId: message.sessionId,
|
|
1215
|
+
agent: typeof message.agent === "string" ? message.agent : defaultAgentName,
|
|
1216
|
+
...(typeof store === "string" ? { store } : {}),
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// The message a delivery names, as queuedMessage() has it, or null for one
|
|
1221
|
+
// no sender could have queued on `backend`.
|
|
1222
|
+
function deliveredMessage(backend, delivered) {
|
|
1223
|
+
if (!delivered || typeof delivered !== "object" || !messageTypes.has(delivered.type)) return null;
|
|
1224
|
+
if (!validId(delivered.messageId) || !backend.validSessionId(delivered.sessionId)) return null;
|
|
1225
|
+
if (delivered.agent !== undefined && typeof delivered.agent !== "string") return null;
|
|
1226
|
+
return queuedMessage(delivered);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Whether the session's log holds the input `message` names, or names it
|
|
1230
|
+
// among those a turn already took.
|
|
1231
|
+
const knownMessage = (state, message) => state.inputKeys.has(`${message.type}:${message.messageId}`) || state.consumed.has(message.messageId);
|
|
1232
|
+
|
|
1233
|
+
// The most a durable input may hold: its content blocks with its caller's
|
|
1234
|
+
// context and its session object's settings, as JSON. The engine refuses a
|
|
1235
|
+
// prompt frame over 8 MiB (`maxPromptFrameBytes` in fx-sdk.js), so a larger
|
|
1236
|
+
// input could never run; it is refused before anything is stored.
|
|
1237
|
+
const maxInputBytes = 8 * 1024 * 1024;
|
|
1238
|
+
const jsonBytes = (value) => (value === undefined ? 0 : encoder.encode(JSON.stringify(value)).byteLength);
|
|
1239
|
+
// A JSON value as it stands now, so what libfx measures is what it stores,
|
|
1240
|
+
// however the caller changes its object later. Pure.
|
|
1241
|
+
const jsonSnapshot = (value) => {
|
|
1242
|
+
const text = JSON.stringify(value);
|
|
1243
|
+
return text === undefined ? undefined : JSON.parse(text);
|
|
1244
|
+
};
|
|
1245
|
+
const inputTooLarge = (bytes) => Object.assign(
|
|
1246
|
+
new RangeError(`a durable input with its context and settings is ${bytes} bytes; the most is ${maxInputBytes}`),
|
|
1247
|
+
{ code: "FX_INPUT_TOO_LARGE", bytes, limit: maxInputBytes },
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
// What a message adds to its session's log: a restored agent's first
|
|
1133
1251
|
// checkpoint and the message's input, a prompt with its caller's context and
|
|
1134
|
-
// its session object's settings.
|
|
1135
|
-
//
|
|
1252
|
+
// its session object's settings. Only the sender calls it, before it queues
|
|
1253
|
+
// the message, so what a turn runs and as whom comes from the app's own
|
|
1254
|
+
// call, never from a queue delivery. Nothing the log already holds. Pure.
|
|
1136
1255
|
function messageEntries(state, message) {
|
|
1137
1256
|
const entries = [];
|
|
1138
1257
|
const named = typeof message.agent === "string" ? { agent: message.agent } : {};
|
|
@@ -1141,7 +1260,7 @@ function messageEntries(state, message) {
|
|
|
1141
1260
|
}
|
|
1142
1261
|
const key = `${message.type}:${message.messageId}`;
|
|
1143
1262
|
const known = state.inputKeys.has(key) || state.consumed.has(message.messageId) || idInUse(state, message);
|
|
1144
|
-
if ((message.type
|
|
1263
|
+
if (messageTypes.has(message.type) && !known) {
|
|
1145
1264
|
entries.push({
|
|
1146
1265
|
k: "input",
|
|
1147
1266
|
key,
|
|
@@ -1174,9 +1293,9 @@ const messageIdInUse = (message) => Object.assign(
|
|
|
1174
1293
|
{ code: "FX_MESSAGE_ID_IN_USE" },
|
|
1175
1294
|
);
|
|
1176
1295
|
|
|
1177
|
-
// Writes what a
|
|
1178
|
-
//
|
|
1179
|
-
// message that reuses another input's id writes nothing and throws.
|
|
1296
|
+
// Writes what a message adds to its session, in order, and returns how many
|
|
1297
|
+
// entries it wrote. Only the sender calls it, before it queues the message.
|
|
1298
|
+
// A message that reuses another input's id writes nothing and throws.
|
|
1180
1299
|
async function recordMessage(log, state, message) {
|
|
1181
1300
|
if (idInUse(state, message)) throw messageIdInUse(message);
|
|
1182
1301
|
const entries = messageEntries(state, message);
|
|
@@ -1259,23 +1378,30 @@ class SessionWorker {
|
|
|
1259
1378
|
}
|
|
1260
1379
|
|
|
1261
1380
|
// One queue delivery, `delivery` its message's id and attempt when the
|
|
1262
|
-
// queue reports them. Its
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1381
|
+
// queue reports them. Its sender stored its input before queueing it, so a
|
|
1382
|
+
// steer or cancel reaches the running turn even before this runs. A
|
|
1383
|
+
// delivery the session's log does not hold is not from a sender and is
|
|
1384
|
+
// dropped before anything is written, whether or not its session exists
|
|
1385
|
+
// (`tests/tla/sdk/TenantIsolation.tla` OnlySentRuns). Then it runs the
|
|
1386
|
+
// session when no live worker holds it. Returns `{ timeoutSeconds }` to
|
|
1387
|
+
// have the same message delivered again later.
|
|
1266
1388
|
async consume(message, delivery = null) {
|
|
1267
1389
|
const log = await this.backend.session(this.sessionId);
|
|
1390
|
+
let entries = await log.read();
|
|
1391
|
+
let state = this.fold(entries, delivery);
|
|
1392
|
+
if (!knownMessage(state, message)) {
|
|
1393
|
+
this.agent.emit("session.refused", { sessionId: this.sessionId, messageId: message.messageId, reason: "unknown message" });
|
|
1394
|
+
return undefined;
|
|
1395
|
+
}
|
|
1268
1396
|
await this.backend.refreshDeadline?.();
|
|
1269
1397
|
// When this delivery's function stops, if it does.
|
|
1270
1398
|
const deadline = this.backend.deadline() ?? (this.backend.maxDurationMs === null ? null : Date.now() + this.backend.maxDurationMs);
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
//
|
|
1274
|
-
// agent that sent its first message. A message that reached another
|
|
1275
|
-
// agent, as one sent to another agent's session does, goes on to it.
|
|
1399
|
+
// A session runs only on the agent it belongs to, the one its first
|
|
1400
|
+
// stored input names. A message that reached another agent, as one sent
|
|
1401
|
+
// to another agent's session does, goes on to it.
|
|
1276
1402
|
const owner = state.agent ?? agentOf(message);
|
|
1277
1403
|
if (owner !== this.agent.name) {
|
|
1278
|
-
await this.backend.queue.send({ ...message, agent: owner }, {
|
|
1404
|
+
await this.backend.queue.send(queuedMessage({ ...message, agent: owner }, this.backend.store), {
|
|
1279
1405
|
idempotencyKey: `${message.sessionId}:${message.type}:${message.messageId}:to:${owner}`,
|
|
1280
1406
|
});
|
|
1281
1407
|
this.agent.emit("session.forwarded", { sessionId: this.sessionId, agent: owner });
|
|
@@ -1283,26 +1409,13 @@ class SessionWorker {
|
|
|
1283
1409
|
}
|
|
1284
1410
|
for (let claims = 0; claims < 8; claims += 1) {
|
|
1285
1411
|
// A session with no open turn starts its next prompt at once: the
|
|
1286
|
-
// first model request goes out while the
|
|
1287
|
-
//
|
|
1412
|
+
// first model request goes out while the lease is written, and
|
|
1413
|
+
// nothing else is written until it lands.
|
|
1288
1414
|
const early = this.running || state.lease ? null : earlyStart(entries, state, message, (items) => this.fold(items, delivery));
|
|
1289
1415
|
let outcome;
|
|
1290
1416
|
if (early) {
|
|
1291
1417
|
outcome = await this.claim(log, state, deadline, early, delivery);
|
|
1292
1418
|
} else {
|
|
1293
|
-
let wrote;
|
|
1294
|
-
try {
|
|
1295
|
-
wrote = await recordMessage(log, state, message);
|
|
1296
|
-
} catch (error) {
|
|
1297
|
-
if (error?.code !== "FX_MESSAGE_ID_IN_USE") throw error;
|
|
1298
|
-
// Its sender could not hear it: a durable queue took it first.
|
|
1299
|
-
this.agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
|
|
1300
|
-
return undefined;
|
|
1301
|
-
}
|
|
1302
|
-
if (wrote > 0) {
|
|
1303
|
-
entries = await log.read();
|
|
1304
|
-
state = this.fold(entries, delivery);
|
|
1305
|
-
}
|
|
1306
1419
|
if (this.running) return { timeoutSeconds: runningBackstopSeconds };
|
|
1307
1420
|
if (!state.hasWork) {
|
|
1308
1421
|
if (message.type === "resume") await this.answerIdle(log, message);
|
|
@@ -1755,7 +1868,11 @@ class SessionWorker {
|
|
|
1755
1868
|
// `{ stopped }` when its harness stopped under it, or `{ result }` for a
|
|
1756
1869
|
// turn that ended, whose end the caller writes.
|
|
1757
1870
|
async drive(log, harness, turn, messageId, state, ui) {
|
|
1758
|
-
|
|
1871
|
+
// The inputs `state` was folded from are placed already: the fold gave
|
|
1872
|
+
// each a turn, joined it to one, or spent it. The poll applies only the
|
|
1873
|
+
// inputs stored after them. After an early start, that includes this
|
|
1874
|
+
// message's own input, landing past `since`.
|
|
1875
|
+
const applied = new Set(state.inputKeys);
|
|
1759
1876
|
let since = String(state.maxCursor);
|
|
1760
1877
|
let polling = false;
|
|
1761
1878
|
let again = false;
|
|
@@ -1770,6 +1887,9 @@ class SessionWorker {
|
|
|
1770
1887
|
void Promise.resolve().then(() => turn.endForSteer?.()).catch(() => {});
|
|
1771
1888
|
};
|
|
1772
1889
|
const steerTurn = (steer) => {
|
|
1890
|
+
// One id names one input, so a steer with this turn's id is its own
|
|
1891
|
+
// opener or one that reused the id, which the log never runs.
|
|
1892
|
+
if (steer.messageId === messageId) return;
|
|
1773
1893
|
if (!joinable(steer.input)) endTurn();
|
|
1774
1894
|
else if (!ending) void turn.steer(steer.input, steer.messageId).catch(() => {});
|
|
1775
1895
|
};
|
|
@@ -2132,10 +2252,12 @@ function turnView({ messageId, resumeRequest = null, start }) {
|
|
|
2132
2252
|
* once, or ends with an error when it never started; when its harness stops
|
|
2133
2253
|
* twice more, even to cancel it, the session runs no more turns.
|
|
2134
2254
|
*
|
|
2135
|
-
* `defaultDurability()` picks the durability when the caller names none,
|
|
2136
|
-
* `
|
|
2255
|
+
* `defaultDurability()` picks the durability when the caller names none, and
|
|
2256
|
+
* `defaultShared` says it hands every such agent the same one, as Node's
|
|
2257
|
+
* does, so that agents created without a durability share a store. `name` is
|
|
2258
|
+
* the factory's name in errors, and `label` the agent's.
|
|
2137
2259
|
*/
|
|
2138
|
-
export function createDurableAgentFactory({ harness, defaultDurability, name = "createAgent", label = "agent" }) {
|
|
2260
|
+
export function createDurableAgentFactory({ harness, defaultDurability, defaultShared = false, name = "createAgent", label = "agent" }) {
|
|
2139
2261
|
return function createDurableAgent(options = {}) {
|
|
2140
2262
|
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
2141
2263
|
throw new TypeError(`${name}() options must be an object`);
|
|
@@ -2181,10 +2303,29 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2181
2303
|
if (closed) return Promise.reject(new Error(`${label} is closed`));
|
|
2182
2304
|
backendPromise ??= (async () => {
|
|
2183
2305
|
const chosen = await durabilityOf();
|
|
2306
|
+
// An agent created without a durability learns its store only now.
|
|
2307
|
+
if (member.store !== storeOf(chosen)) {
|
|
2308
|
+
member.store = storeOf(chosen);
|
|
2309
|
+
try {
|
|
2310
|
+
claimName(agentName, member);
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
leaveGroup(agentsByName, agentName, registered, member);
|
|
2313
|
+
throw error;
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2184
2316
|
chosenDurability = chosen;
|
|
2185
2317
|
const created = await createBackend(chosen);
|
|
2186
2318
|
await created.start?.();
|
|
2187
|
-
unlisten = created.listen((
|
|
2319
|
+
unlisten = created.listen((delivered, meta) => {
|
|
2320
|
+
// Only which input a delivery names is read from it: the rest of
|
|
2321
|
+
// its body, which a forged delivery would choose, is ignored.
|
|
2322
|
+
const message = deliveredMessage(created, delivered);
|
|
2323
|
+
if (!message) {
|
|
2324
|
+
emit("session.refused", { reason: "malformed message" });
|
|
2325
|
+
return undefined;
|
|
2326
|
+
}
|
|
2327
|
+
return receive(created, message, deliveryOf(meta));
|
|
2328
|
+
}, agentName);
|
|
2188
2329
|
return created;
|
|
2189
2330
|
})();
|
|
2190
2331
|
return backendPromise;
|
|
@@ -2192,8 +2333,13 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2192
2333
|
// The durability the agent's sessions live in, chosen once.
|
|
2193
2334
|
const durabilityOf = () => (durabilityPromise ??= durability ? Promise.resolve(durability) : defaultDurability());
|
|
2194
2335
|
// In the process's agents from now, so a route that receives a message
|
|
2195
|
-
// for this agent finds it before anything uses it.
|
|
2196
|
-
|
|
2336
|
+
// for this agent finds it before anything uses it. One created without a
|
|
2337
|
+
// durability claims the default's store until it knows which that is:
|
|
2338
|
+
// the store every such agent shares where the factory hands them all one
|
|
2339
|
+
// durability, and a store of its own where each gets its own.
|
|
2340
|
+
const unresolved = defaultShared ? defaultDurability : Symbol("default store");
|
|
2341
|
+
const member = { open: backend, durability: durabilityOf, definition: definitionOf(harnessOptions), store: durability ? storeOf(durability) : unresolved, defaults: durability ? null : unresolved };
|
|
2342
|
+
claimName(agentName, member);
|
|
2197
2343
|
const registered = joinGroup(agentsByName, agentName, member);
|
|
2198
2344
|
// One delivery of `message` to the session's worker here: from the
|
|
2199
2345
|
// queue, with `delivery` naming the queue's message and attempt, or with
|
|
@@ -2235,11 +2381,17 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2235
2381
|
|
|
2236
2382
|
const openSession = (requestedId, sessionOptions = {}) => {
|
|
2237
2383
|
if (requestedId !== undefined && !validId(requestedId)) throw new TypeError(`session id must be ${idRule}`);
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
const
|
|
2384
|
+
let context;
|
|
2385
|
+
try { context = jsonSnapshot(sessionOptions?.context); } catch { throw new TypeError("session context must be JSON"); }
|
|
2386
|
+
const settings = jsonSnapshot(plugged.settings?.(sessionOptions ?? {}));
|
|
2387
|
+
// What every input this session object sends carries besides itself.
|
|
2388
|
+
const carried = jsonBytes(context) + jsonBytes(settings);
|
|
2389
|
+
// Refuses an input too large to store before anything is stored
|
|
2390
|
+
// (`tests/tla/sdk/TenantIsolation.tla` StoredWithinLimit).
|
|
2391
|
+
const admit = (input) => {
|
|
2392
|
+
const bytes = jsonBytes(input) + carried;
|
|
2393
|
+
if (bytes > maxInputBytes) throw inputTooLarge(bytes);
|
|
2394
|
+
};
|
|
2243
2395
|
let id = requestedId ?? null;
|
|
2244
2396
|
let seeded = seed === undefined || requestedId !== undefined;
|
|
2245
2397
|
const resolved = backend().then((created) => {
|
|
@@ -2258,12 +2410,15 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2258
2410
|
...(settings === undefined ? {} : { settings }),
|
|
2259
2411
|
};
|
|
2260
2412
|
const full = { ...message, ...extra, agent: agentName, sessionId };
|
|
2261
|
-
//
|
|
2262
|
-
//
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2413
|
+
// The input and its caller's context reach the log here, on the
|
|
2414
|
+
// app's own call, before the caller hears it was accepted. The queue
|
|
2415
|
+
// carries only which input to run, so a delivery cannot choose what
|
|
2416
|
+
// a turn runs or as whom (`tests/tla/sdk/TenantIsolation.tla`
|
|
2417
|
+
// OnlySentRuns), and a queue that ends with the process loses no
|
|
2418
|
+
// input.
|
|
2419
|
+
const log = await created.session(sessionId);
|
|
2420
|
+
await recordMessage(log, foldSessionLog(await log.read()), full);
|
|
2421
|
+
const queued = queuedMessage(full, created.store);
|
|
2267
2422
|
const idempotencyKey = `${sessionId}:${message.type}:${message.messageId}`;
|
|
2268
2423
|
if (inRequest && created.runInRequest) {
|
|
2269
2424
|
await created.refreshDeadline?.();
|
|
@@ -2275,16 +2430,16 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2275
2430
|
// if the request died, the backstop takes the session over and
|
|
2276
2431
|
// continues the turn.
|
|
2277
2432
|
const delaySeconds = Math.ceil(Math.max(0, deadline - Date.now()) / 1000) + inlineBackstopMarginSeconds;
|
|
2278
|
-
const { messageId: backstop } = await created.queue.send(
|
|
2433
|
+
const { messageId: backstop } = await created.queue.send(queued, { idempotencyKey, delaySeconds }) ?? {};
|
|
2279
2434
|
seeded = true;
|
|
2280
2435
|
const delivery = backstop ? { messageId: backstop, attempt: 0 } : null;
|
|
2281
2436
|
// receive() reports a failure to the host; the backstop retries it.
|
|
2282
|
-
const work = receive(created,
|
|
2437
|
+
const work = receive(created, queued, delivery, { inRequest: true }).then(() => {}, () => {});
|
|
2283
2438
|
created.waitUntil?.(work);
|
|
2284
2439
|
return;
|
|
2285
2440
|
}
|
|
2286
2441
|
}
|
|
2287
|
-
await created.queue.send(
|
|
2442
|
+
await created.queue.send(queued, { idempotencyKey });
|
|
2288
2443
|
seeded = true;
|
|
2289
2444
|
};
|
|
2290
2445
|
const startTurn = (type, messageId, input, retried = false, aborted = false) => {
|
|
@@ -2369,6 +2524,7 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2369
2524
|
if (Array.isArray(input) && input.some((block) => block?.type === "image" && block.data !== undefined && typeof block.data !== "string")) {
|
|
2370
2525
|
throw new TypeError("a durable prompt carries image data as a base64 string");
|
|
2371
2526
|
}
|
|
2527
|
+
admit(input);
|
|
2372
2528
|
const signal = promptOptions.signal;
|
|
2373
2529
|
if (signal !== undefined && typeof signal?.addEventListener !== "function") throw new TypeError("prompt signal must be an AbortSignal");
|
|
2374
2530
|
const turn = startTurn("prompt", messageId, input, promptOptions.messageId !== undefined, signal?.aborted === true);
|
|
@@ -2476,6 +2632,7 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2476
2632
|
if (steerOptions === null || typeof steerOptions !== "object") throw new TypeError("steer options must be an object");
|
|
2477
2633
|
const messageId = steerOptions.messageId ?? newId("str");
|
|
2478
2634
|
if (!validId(messageId)) throw new TypeError(`messageId must be ${idRule}`);
|
|
2635
|
+
admit(input);
|
|
2479
2636
|
await send({ type: "steer", messageId, input });
|
|
2480
2637
|
return { messageId };
|
|
2481
2638
|
},
|
|
@@ -2516,7 +2673,11 @@ export function createDurableAgentFactory({ harness, defaultDurability, name = "
|
|
|
2516
2673
|
// Waits for what a session's running turn has written so far.
|
|
2517
2674
|
settled: async (sessionId) => { await workers.get(sessionId)?.settled?.(); },
|
|
2518
2675
|
// Delivers a queue message to this agent as its queue would.
|
|
2519
|
-
deliver: async (
|
|
2676
|
+
deliver: async (delivered, delivery) => {
|
|
2677
|
+
const created = await backend();
|
|
2678
|
+
const message = deliveredMessage(created, delivered);
|
|
2679
|
+
return message ? receive(created, message, delivery) : undefined;
|
|
2680
|
+
},
|
|
2520
2681
|
liveWorkers: () => workers.size,
|
|
2521
2682
|
// A session's log as one worker holds it, with its own view of the
|
|
2522
2683
|
// chain.
|
package/fx-core.wasm
CHANGED
|
Binary file
|