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/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 (input.type !== "cancel" || consumed.has(input.messageId)) continue;
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.open();
673
- if (typeof created.deliverHere === "function") return 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
- // A route takes its deployment's deliveries, which every route durability
683
- // of the same kind in the process shares, such as each `vercel()` an app
684
- // creates: the agent a message names on this durability, or on another of
685
- // that kind.
686
- const agentOnRoute = async (name, durability) => (await agentSharing(name, durability)) ??
687
- agentNamed(name, (other) => other !== durability && other.name === durability.name && typeof other.world === "function" && other.create === undefined);
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 number of agents.
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.open();
699
- if (created.viaRoute === true) return created.queueHandler(request);
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 agentOnRoute(named, durability);
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
- // What a queue message adds to its session's log: a restored agent's first
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
- // Nothing the log already holds. Pure.
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 === "prompt" || message.type === "steer" || message.type === "cancel") && !known) {
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 queue message adds to its session, in order, and returns how
1178
- // many entries it wrote. The sender and the consumer can both call it. A
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 input reaches the log at once, even while this
1263
- // process runs the session, so a steer or cancel reaches the running turn.
1264
- // Then it runs the session when no live worker holds it. Returns
1265
- // `{ timeoutSeconds }` to have the same message delivered again later.
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
- let entries = await log.read();
1272
- let state = this.fold(entries, delivery);
1273
- // A session runs only on the agent it belongs to, which a new one is the
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 input and the lease are
1287
- // written, and nothing else is written until both land.
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
- const applied = new Set();
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
- * `name` is the factory's name in errors, and `label` the agent's.
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((message, meta) => receive(created, message, deliveryOf(meta)), agentName);
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
- const member = { open: backend, durability: durabilityOf };
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
- const context = sessionOptions?.context;
2239
- if (context !== undefined) {
2240
- try { JSON.stringify(context); } catch { throw new TypeError("session context must be JSON"); }
2241
- }
2242
- const settings = plugged.settings?.(sessionOptions ?? {});
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
- // A queue that ends with the process could lose the message, so its
2262
- // input reaches the log before the caller hears it was accepted.
2263
- if (!created.queueDurable) {
2264
- const log = await created.session(sessionId);
2265
- await recordMessage(log, foldSessionLog(await log.read()), full);
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(full, { idempotencyKey, delaySeconds }) ?? {};
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, full, delivery, { inRequest: true }).then(() => {}, () => {});
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(full, { idempotencyKey });
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 (message, delivery) => receive(await backend(), message, delivery),
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