libfx 0.0.16 → 0.0.17

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 CHANGED
@@ -268,6 +268,8 @@ const agent = createFxAgent({
268
268
 
269
269
  Return a string to send it as is, or any other value to send it as JSON. A thrown error reaches the model as a failed tool result. To return images, return `{ type: "libfx.tool-result", text, images }`, with base64 image data.
270
270
 
271
+ When the model writes a number, integer, boolean, array, or object argument as JSON text, such as `"40"` for a field that `inputSchema` types as an integer, `execute` receives it as that type. Every other value arrives as the model wrote it.
272
+
271
273
  Validate the input and authorize the action inside `execute`. libfx orders the calls but grants nothing itself.
272
274
 
273
275
  On the native backend, calls to idempotent tools from one model response run at the same time, up to 16 at once. A tool that isn't idempotent runs alone, and so does one marked `writes: true`. Mark a tool `writes: false` to let it overlap with others anyway. On WebAssembly, calls run one after another.
@@ -314,6 +316,8 @@ createFxAgent({ model: { id: "anthropic/claude-opus-5.5", effort: "high", fast:
314
316
 
315
317
  AI Gateway is the default. libfx uses the first credential it finds: the `apiKey` option, then `AI_GATEWAY_API_KEY`, then on Vercel the deployment's OIDC token.
316
318
 
319
+ libfx asks AI Gateway to cache the start of each request, as the fx CLI does. Later requests in a session start with the same instructions, tools, and earlier messages, so models that support prompt caching read that part from the cache instead of processing it again.
320
+
317
321
  ### ChatGPT plans
318
322
 
319
323
  An agent can run on a user's ChatGPT plan through Codex. Set `provider: "codex"` and give libfx the user's credentials:
@@ -10,14 +10,18 @@ const encoder = new TextEncoder();
10
10
  const reservedNames = new Set(["gateway", "codex", "grok"]);
11
11
  const namePattern = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
12
12
  const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
13
- const providerFields = new Set(["name", "baseUrl", "models"]);
13
+ const providerFields = new Set(["name", "baseUrl", "models", "modelDefaults", "titleModel", "visionModel"]);
14
14
  // libfx option names to the CLI's settings names, which the engine parses.
15
15
  const modelFields = {
16
16
  contextWindow: "context_window",
17
17
  maxOutputTokens: "max_output_tokens",
18
18
  supportsToolUse: "supports_tool_use",
19
19
  supportsVision: "supports_vision",
20
+ outputLimitField: "output_limit_field",
21
+ request: "request",
22
+ reasoningEfforts: "reasoning_efforts",
20
23
  };
24
+ const outputLimitFields = new Set(["max_tokens", "max_completion_tokens"]);
21
25
 
22
26
  function isPlainObject(value) {
23
27
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -47,36 +51,61 @@ const maxModels = 256;
47
51
  const maxModelIdBytes = 1024;
48
52
  const maxLimit = 2 ** 32 - 1;
49
53
 
54
+ function validModelId(id) {
55
+ return typeof id === "string" && id.length > 0 && id.trim() === id && !/[\u0000-\u001f\u007f]/.test(id) && encoder.encode(id).length <= maxModelIdBytes;
56
+ }
57
+
58
+ // One model's fields, or the provider's defaults for every model, in the
59
+ // settings' names. `where` names it in a message.
60
+ function modelEntry(model, where) {
61
+ if (!isPlainObject(model)) throw new TypeError(`${where} must be an object`);
62
+ const entry = {};
63
+ for (const [field, value] of Object.entries(model)) {
64
+ const name = modelFields[field];
65
+ if (name === undefined) throw new TypeError(`${where} has unknown field ${field}`);
66
+ if (field === "contextWindow" || field === "maxOutputTokens") {
67
+ if (!(Number.isInteger(value) && value > 0 && value <= maxLimit)) throw new TypeError(`${where}.${field} must be a positive integer`);
68
+ } else if (field === "outputLimitField") {
69
+ if (!outputLimitFields.has(value)) throw new TypeError(`${where}.outputLimitField must be "max_tokens" or "max_completion_tokens"`);
70
+ } else if (field === "request") {
71
+ // The engine refuses a field it writes itself, such as messages.
72
+ if (!isPlainObject(value)) throw new TypeError(`${where}.request must be an object of request fields`);
73
+ } else if (field === "reasoningEfforts") {
74
+ if (!isPlainObject(value) || !Object.values(value).every(isPlainObject)) {
75
+ throw new TypeError(`${where}.reasoningEfforts must map each effort level to an object of request fields`);
76
+ }
77
+ } else if (typeof value !== "boolean") {
78
+ throw new TypeError(`${where}.${field} must be a boolean`);
79
+ }
80
+ entry[name] = value;
81
+ }
82
+ if (model.contextWindow !== undefined && model.maxOutputTokens !== undefined && model.maxOutputTokens >= model.contextWindow) {
83
+ throw new RangeError(`${where}.maxOutputTokens must be less than contextWindow`);
84
+ }
85
+ return entry;
86
+ }
87
+
50
88
  function modelMetadata(models) {
51
89
  if (!isPlainObject(models)) throw new TypeError("provider.models must be an object keyed by model id");
52
90
  const entries = Object.entries(models);
53
91
  if (entries.length > maxModels) throw new RangeError(`provider.models lists more than ${maxModels} models`);
54
92
  const metadata = {};
55
93
  for (const [id, model] of entries) {
56
- if (id.length === 0 || id.trim() !== id || /[\u0000-\u001f\u007f]/.test(id) || encoder.encode(id).length > maxModelIdBytes) {
57
- throw new TypeError(`provider.models has an invalid model id "${id}"`);
58
- }
59
- if (!isPlainObject(model)) throw new TypeError(`provider.models["${id}"] must be an object`);
60
- const entry = {};
61
- for (const [field, value] of Object.entries(model)) {
62
- const name = modelFields[field];
63
- if (name === undefined) throw new TypeError(`provider.models["${id}"] has unknown field ${field}`);
64
- const limit = field === "contextWindow" || field === "maxOutputTokens";
65
- if (limit ? !(Number.isInteger(value) && value > 0 && value <= maxLimit) : typeof value !== "boolean") {
66
- throw new TypeError(`provider.models["${id}"].${field} must be ${limit ? "a positive integer" : "a boolean"}`);
67
- }
68
- entry[name] = value;
69
- }
70
- if (model.contextWindow !== undefined && model.maxOutputTokens !== undefined && model.maxOutputTokens >= model.contextWindow) {
71
- throw new RangeError(`provider.models["${id}"].maxOutputTokens must be less than contextWindow`);
72
- }
73
- metadata[id] = entry;
94
+ if (!validModelId(id)) throw new TypeError(`provider.models has an invalid model id "${id}"`);
95
+ metadata[id] = modelEntry(model, `provider.models["${id}"]`);
74
96
  }
75
97
  return metadata;
76
98
  }
77
99
 
100
+ function helperModel(value, field) {
101
+ if (value === undefined) return {};
102
+ if (!validModelId(value)) throw new TypeError(`provider.${field} must be a model id`);
103
+ return { [field === "titleModel" ? "title_model" : "vision_model"]: value };
104
+ }
105
+
78
106
  /**
79
- * Checks a custom provider `{ name, baseUrl, models? }` and returns its name,
107
+ * Checks a custom provider `{ name, baseUrl, models?, modelDefaults?,
108
+ * titleModel?, visionModel? }` and returns its name,
80
109
  * the chat URL its key may go to, and the settings-style JSON the engine
81
110
  * parses. The engine applies the CLI's complete rules; these checks only catch
82
111
  * mistakes early with a clearer message.
@@ -86,7 +115,7 @@ export function customProviderDefinition(provider) {
86
115
  for (const field of Object.keys(provider)) {
87
116
  if (!providerFields.has(field)) throw new TypeError(`a custom provider has unknown field ${field}`);
88
117
  }
89
- const { name, baseUrl, models } = provider;
118
+ const { name, baseUrl, models, modelDefaults, titleModel, visionModel } = provider;
90
119
  if (typeof name !== "string" || !namePattern.test(name) || reservedNames.has(name.toLowerCase())) {
91
120
  throw new TypeError("provider.name must be a letter followed by letters, digits, _ or -, at most 64 characters, and not gateway, codex or grok");
92
121
  }
@@ -96,6 +125,9 @@ export function customProviderDefinition(provider) {
96
125
  base_url: baseUrl,
97
126
  // The host adds the key, so the engine's copy has none.
98
127
  auth: { type: "none" },
128
+ ...helperModel(titleModel, "titleModel"),
129
+ ...helperModel(visionModel, "visionModel"),
130
+ ...(modelDefaults === undefined ? {} : { model_defaults: modelEntry(modelDefaults, "provider.modelDefaults") }),
99
131
  ...(models === undefined ? {} : { model_metadata: modelMetadata(models) }),
100
132
  };
101
133
  return { name, chatUrl, json: JSON.stringify({ [name]: definition }) };
package/durable.js CHANGED
@@ -747,6 +747,11 @@ function slotOf(event) {
747
747
  return Number(match[1]);
748
748
  }
749
749
 
750
+ // The slot of the event a write returned, or NaN when the World returned none.
751
+ function slotWritten(result) {
752
+ return Number(eventIdPattern.exec(String(result?.event?.eventId ?? ""))?.[1]);
753
+ }
754
+
750
755
  function logEntryOf(event) {
751
756
  if (event?.eventType !== "step_created" || event.eventData?.stepName !== logStep) return null;
752
757
  const input = event.eventData.input;
@@ -766,6 +771,7 @@ const missingStreamFastMs = 2000;
766
771
  // may hold together, least recently read dropped first.
767
772
  const maxLogViews = 32;
768
773
  const maxLogViewChars = 32 * 1024 * 1024;
774
+ const maxMintedIds = 1024;
769
775
 
770
776
  // Whether a newest-first listing showed every slot from the lowest it
771
777
  // listed to the newest. Pure.
@@ -1098,24 +1104,41 @@ function createWorldBackend(world, durability) {
1098
1104
  }
1099
1105
  return pending;
1100
1106
  };
1107
+ // The newest slot the last read() returned every entry through, or
1108
+ // null when that read could not show it had them all: 0 when it listed
1109
+ // nothing, as for a run not created yet.
1110
+ let covered = null;
1111
+ // The run's creation events hold no entry, so a read that showed every
1112
+ // entry through the slot before one still does through it.
1113
+ const passOver = (slot) => {
1114
+ if (covered !== null && slot === covered + 1) covered = slot;
1115
+ };
1116
+ // Resolves true when this call's own `run_created` made the run: the
1117
+ // World creates a run only when none exists (a second one is refused,
1118
+ // 409), and its first event takes the first slot. A run that did not
1119
+ // exist held no event and no stream.
1101
1120
  const createRun = async () => {
1121
+ let made = false;
1102
1122
  try {
1103
1123
  const deploymentId = typeof world.getDeploymentId === "function" ? await world.getDeploymentId() : "libfx";
1104
- await world.events.create(runId, {
1124
+ const slot = slotWritten(await world.events.create(runId, {
1105
1125
  eventType: "run_created",
1106
1126
  ...spec,
1107
1127
  eventData: { deploymentId, workflowName: "libfx/session", input: encoder.encode("{}") },
1108
- });
1128
+ }));
1129
+ made = slot === firstEventSlot;
1130
+ passOver(slot);
1109
1131
  } catch (error) {
1110
1132
  // Another worker created it first.
1111
1133
  if (!/exist|conflict|409|already/i.test(String(error?.message ?? "")) && error?.status !== 409) throw error;
1112
1134
  }
1113
1135
  try {
1114
- await world.events.create(runId, { eventType: "run_started", ...spec });
1136
+ passOver(slotWritten(await world.events.create(runId, { eventType: "run_started", ...spec })));
1115
1137
  } catch (error) {
1116
1138
  if (!/exist|conflict|409|already|started|status/i.test(String(error?.message ?? "")) && error?.status !== 409) throw error;
1117
1139
  }
1118
1140
  created.add(runId);
1141
+ return made;
1119
1142
  };
1120
1143
  // Events newest first, down to and excluding slot `stopBelow`.
1121
1144
  const newestFirst = async (stopBelow, stopAtCheckpoint) => {
@@ -1175,9 +1198,6 @@ function createWorldBackend(world, durability) {
1175
1198
  cursor = page.cursor;
1176
1199
  }
1177
1200
  };
1178
- // The newest slot the last read() returned every entry through, or
1179
- // null when that read could not show it had them all.
1180
- let covered = null;
1181
1201
  const fullRead = async () => {
1182
1202
  const read = await newestFirst(0, true);
1183
1203
  const entries = read.events.reverse();
@@ -1191,12 +1211,31 @@ function createWorldBackend(world, durability) {
1191
1211
  covered = read.newest;
1192
1212
  } else {
1193
1213
  forgetView(runId);
1194
- covered = null;
1214
+ covered = read.missing || read.listed === 0 ? 0 : null;
1195
1215
  }
1196
1216
  return entries;
1197
1217
  };
1198
1218
  return {
1199
1219
  newest: () => covered,
1220
+ // Creates the run, as its first append would, and resolves true only
1221
+ // when this call's own write made it: the log was then empty and its
1222
+ // stream had no line, as a read just before the write would have
1223
+ // shown, so the sender need not read either. False, without writing,
1224
+ // when this process already created the run or is creating it; false,
1225
+ // after the same writes, when the run existed or the World cannot show
1226
+ // which. The caller then reads as before.
1227
+ async fresh() {
1228
+ if (created.has(runId) || creating.has(runId)) return false;
1229
+ // As a read that found no run leaves it, so the creation events
1230
+ // this write lands pass it over. Of a run it did not make, this
1231
+ // object knows nothing; the caller then reads it.
1232
+ covered = 0;
1233
+ const made = await ensureRun();
1234
+ if (!made) covered = null;
1235
+ // As a read that found no run notes it.
1236
+ if (made) noteSnapshot({ seq: 0, seqInBand: inBandAtRunCreation }, "null");
1237
+ return made;
1238
+ },
1200
1239
  async read() {
1201
1240
  const view = viewOf(runId);
1202
1241
  if (!view) return fullRead();
@@ -1596,7 +1635,9 @@ async function recordMessage(log, state, message) {
1596
1635
  * The log a sender read and then extended with its own entries, as the
1597
1636
  * worker it starts in the same request may use it instead of reading the
1598
1637
  * log again. Only when the sender's read showed every entry through slot
1599
- * `newestSeen`, and every entry the sender then wrote took the next slot:
1638
+ * `newestSeen` (for a run it found missing, the run's creation events it
1639
+ * then saw land hold none), and every entry the sender then wrote took the
1640
+ * next slot:
1600
1641
  * then nothing landed in between, so the view holds every input that comes
1601
1642
  * before this message's. The sender's entries go on as the JSON the World
1602
1643
  * stored, not as the objects it wrote. Null otherwise. Pure.
@@ -1673,9 +1714,22 @@ class SessionWorker {
1673
1714
  this.sessionId = sessionId;
1674
1715
  // Set while this process runs the session; each run holds its own lease.
1675
1716
  this.running = false;
1676
- // Deliveries in progress for the session in this process.
1717
+ // How many claims this worker has started. A read taken at a lower count
1718
+ // may predate a turn that took the inputs it shows pending.
1719
+ this.claimsStarted = 0;
1720
+ // Deliveries in progress for the session in this process, and senders
1721
+ // about to hand one a read (see consume).
1677
1722
  this.deliveries = 0;
1678
1723
  this.holder = null;
1724
+ // A write-free early claim can give way to a backed delivery. Once its
1725
+ // backstop succeeds, it is a normal claim and cannot be displaced here.
1726
+ this.opening = null;
1727
+ }
1728
+
1729
+ // Taken just before a read of the log, for consume to tell whether a turn
1730
+ // ran here since: the claims started, or null while one runs.
1731
+ mark() {
1732
+ return this.running ? null : this.claimsStarted;
1679
1733
  }
1680
1734
 
1681
1735
  // The log folded as `delivery` judges whether a lease's holder lives.
@@ -1693,15 +1747,37 @@ class SessionWorker {
1693
1747
  // have the same message delivered again later.
1694
1748
  // `handoff` is the sender's own session object and the log it read and
1695
1749
  // extended, when the request that took the prompt runs it (see
1696
- // handoffEntries); the worker starts from them instead of reading again.
1697
- async consume(message, delivery = null, handoff = null) {
1750
+ // handoffEntries), with `seen`, what mark() said before that read; the
1751
+ // worker starts from them instead of reading again.
1752
+ // `backstop`, from that request too, is its backstop's send still in
1753
+ // flight, which settles with `delivery`. Until it does, the worker goes on
1754
+ // only to an early start, which writes nothing before it (see claim). A
1755
+ // send that failed ends the delivery; its sender fails the prompt.
1756
+ async consume(message, delivery = null, handoff = null, backstop = null) {
1698
1757
  const log = handoff?.log ?? await this.backend.session(this.sessionId);
1758
+ // The claims this worker had started when the read began, or null when
1759
+ // one was running then, whose writes it may not show.
1760
+ let seen = handoff ? handoff.seen : this.mark();
1699
1761
  let entries = handoff?.entries ?? await log.read();
1700
1762
  let state = this.fold(entries, delivery);
1701
1763
  if (!knownMessage(state, message)) {
1702
1764
  this.agent.emit("session.refused", { sessionId: this.sessionId, messageId: message.messageId, reason: "unknown message" });
1703
1765
  return undefined;
1704
1766
  }
1767
+ // Folded again with the backstop's delivery, which can only end a lease
1768
+ // that stood without it (see redelivers).
1769
+ const awaitBackstop = async () => {
1770
+ if (!backstop) return true;
1771
+ try {
1772
+ delivery = await backstop;
1773
+ } catch {
1774
+ return false;
1775
+ } finally {
1776
+ backstop = null;
1777
+ }
1778
+ state = this.fold(entries, delivery);
1779
+ return true;
1780
+ };
1705
1781
  await this.backend.refreshDeadline?.();
1706
1782
  // When this delivery's function stops, if it does.
1707
1783
  const deadline = this.backend.deadline() ?? (this.backend.maxDurationMs === null ? null : Date.now() + this.backend.maxDurationMs);
@@ -1710,20 +1786,48 @@ class SessionWorker {
1710
1786
  // to another agent's session does, goes on to it.
1711
1787
  const owner = state.agent ?? agentOf(message);
1712
1788
  if (owner !== this.agent.name) {
1789
+ if (!(await awaitBackstop())) return undefined;
1713
1790
  await this.backend.queue.send(queuedMessage({ ...message, agent: owner }, this.backend.store), {
1714
1791
  idempotencyKey: `${message.sessionId}:${message.type}:${message.messageId}:to:${owner}`,
1715
1792
  });
1716
1793
  this.agent.emit("session.forwarded", { sessionId: this.sessionId, agent: owner });
1717
1794
  return undefined;
1718
1795
  }
1796
+ // A turn this worker ran since the read may have run this message,
1797
+ // which the read still shows pending; starting from it would send the
1798
+ // turn's model request twice. A turn still running answers below.
1799
+ const caughtUp = async () => {
1800
+ while (!this.running && seen !== this.claimsStarted) {
1801
+ seen = this.claimsStarted;
1802
+ entries = await log.read();
1803
+ state = this.fold(entries, delivery);
1804
+ }
1805
+ };
1719
1806
  for (let claims = 0; claims < 8; claims += 1) {
1807
+ await caughtUp();
1720
1808
  // A session with no open turn starts its next prompt at once: the
1721
1809
  // first model request goes out while the lease is written, and
1722
1810
  // nothing else is written until it lands.
1723
- const early = this.running || state.lease ? null : earlyStart(entries, state, message, (items) => this.fold(items, delivery));
1811
+ const startable = () => (this.running || state.lease ? null : earlyStart(entries, state, message, (items) => this.fold(items, delivery)));
1812
+ let early = startable();
1813
+ if (!early) {
1814
+ if (!(await awaitBackstop())) return undefined;
1815
+ // Only a write-free opener may be displaced. Never await a live
1816
+ // claim here: its run/turn may be waiting for this delivery's steer.
1817
+ const opening = this.opening;
1818
+ if (opening) {
1819
+ opening.stop();
1820
+ await opening.stopped;
1821
+ }
1822
+ // Fold the durable log again after displacement: this delivery's
1823
+ // backstop can recover earlier stored inputs too, in log order.
1824
+ await caughtUp();
1825
+ early = startable();
1826
+ }
1724
1827
  let outcome;
1725
1828
  if (early) {
1726
- outcome = await this.claim(log, state, deadline, early, delivery);
1829
+ outcome = await this.claim(log, state, deadline, early, delivery, backstop);
1830
+ if (!(await awaitBackstop())) return undefined;
1727
1831
  } else {
1728
1832
  if (this.running) return { timeoutSeconds: runningBackstopSeconds };
1729
1833
  if (!state.hasWork) {
@@ -1752,6 +1856,7 @@ class SessionWorker {
1752
1856
  }
1753
1857
  // The turns ran, or another worker's write came before this claim:
1754
1858
  // read the log again.
1859
+ seen = this.mark();
1755
1860
  entries = await log.read();
1756
1861
  state = this.fold(entries, delivery);
1757
1862
  }
@@ -1762,12 +1867,16 @@ class SessionWorker {
1762
1867
  // the chain there, and runs it. With `early`, what `earlyStart` returned,
1763
1868
  // the message's input is written with the lease and the next turn starts
1764
1869
  // before either lands. The lease names `delivery`, so a later delivery of
1765
- // the same message can tell it lost its holder. Returns what run()
1766
- // returns, or "refused" when another worker's write came before the claim.
1767
- async claim(log, state, deadline, early, delivery = null) {
1870
+ // the same message can tell it lost its holder. With `backstop`, the send
1871
+ // that names that delivery, still in flight: every write waits for it, so
1872
+ // the lease names it, and the engine opens meanwhile. Returns what run()
1873
+ // returns, "refused" when another worker's write came before the claim,
1874
+ // or "unqueued" when the backstop failed or the write-free opener gave way.
1875
+ async claim(log, state, deadline, early, delivery = null, backstop = null) {
1768
1876
  // Claimed before the first await, so a delivery that arrives meanwhile
1769
1877
  // sees the session running here.
1770
1878
  this.running = true;
1879
+ this.claimsStarted += 1;
1771
1880
  this.holder = newId("wkr");
1772
1881
  const lease = {
1773
1882
  k: "lease",
@@ -1779,11 +1888,29 @@ class SessionWorker {
1779
1888
  ...(delivery ? { delivery } : {}),
1780
1889
  };
1781
1890
  this.backend.holding(this.holder, true);
1891
+ let unqueued = false;
1892
+ let opening = null;
1893
+ let stopped;
1894
+ let displaced = false;
1895
+ const revoked = backstop ? new Error("early opener displaced by a backed delivery") : null;
1896
+ const giveWay = backstop ? new Promise((_, reject) => {
1897
+ opening = {
1898
+ stopped: new Promise((resolve) => { stopped = resolve; }),
1899
+ stop: () => { displaced = true; reject(revoked); },
1900
+ };
1901
+ this.opening = opening;
1902
+ }) : null;
1903
+ const sent = backstop ? Promise.race([backstop, giveWay]).then((delivered) => {
1904
+ if (displaced) throw revoked;
1905
+ this.opening = null;
1906
+ if (delivered) lease.delivery = delivered;
1907
+ }) : null;
1908
+ void sent?.catch(() => { unqueued = true; });
1782
1909
  try {
1783
1910
  // Both writes go out at once and neither waits for the other; the
1784
1911
  // claim settles once both have, with the lease's cursor if it landed
1785
- // and the first failure.
1786
- const writes = [log.append(lease), ...(early?.writes ?? []).map((entry) => log.append(entry))];
1912
+ // and the first failure. A failed backstop fails both unmade.
1913
+ const writes = [lease, ...(early?.writes ?? [])].map((entry) => (sent ? sent.then(() => log.append(entry)) : log.append(entry)));
1787
1914
  const claimed = Promise.allSettled(writes).then(async ([leased, ...rest]) => {
1788
1915
  const head = leased.status === "fulfilled" ? leased.value : null;
1789
1916
  const error = leased.status === "rejected" ? leased.reason : rest.find((item) => item.status === "rejected")?.reason ?? null;
@@ -1802,7 +1929,7 @@ class SessionWorker {
1802
1929
  });
1803
1930
  let outcome;
1804
1931
  try {
1805
- outcome = await this.run(log, claimed, lease, deadline, early?.state ?? null);
1932
+ outcome = await this.run(log, claimed, lease, deadline, early?.state ?? null, sent?.then(() => true, () => false));
1806
1933
  } catch (error) {
1807
1934
  if ((await claimed).error === null) throw error;
1808
1935
  }
@@ -1812,14 +1939,17 @@ class SessionWorker {
1812
1939
  // wrote nothing and showed nothing. A fenced one leaves the delivery
1813
1940
  // to read the log and try again; after any other failure the same
1814
1941
  // message runs the session again shortly, once a lease that landed is
1815
- // let go.
1942
+ // let go. One whose backstop failed is its sender's to report.
1816
1943
  if (isFenced(settled.error)) return "refused";
1944
+ if (unqueued) return "unqueued";
1817
1945
  this.agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(settled.error) });
1818
1946
  if (settled.head !== null) await log.append({ k: "release", a: settled.head, holder: this.holder }).catch(() => {});
1819
1947
  return "failed";
1820
1948
  } finally {
1821
1949
  this.backend.holding(this.holder, false);
1822
1950
  this.running = false;
1951
+ if (this.opening === opening) this.opening = null;
1952
+ stopped?.();
1823
1953
  }
1824
1954
  }
1825
1955
 
@@ -1835,8 +1965,9 @@ class SessionWorker {
1835
1965
  // over. With `early`, the state the session was read in, the first turn
1836
1966
  // starts before the claim lands: every write waits for it, and once it
1837
1967
  // failed nothing more is written and the turn stops. The caller answers
1838
- // for a claim that failed.
1839
- async run(raw, claimed, lease, deadline, early) {
1968
+ // for a claim that failed. `backstopSent`, when given, resolves whether
1969
+ // the backstop the lease names was sent without displacing this opener.
1970
+ async run(raw, claimed, lease, deadline, early, backstopSent = null) {
1840
1971
  const agent = this.agent;
1841
1972
  let head = null;
1842
1973
  let failed = null;
@@ -2009,7 +2140,16 @@ class SessionWorker {
2009
2140
  // What the turn it runs first runs with: the open turn's context and
2010
2141
  // settings, or the next prompt's.
2011
2142
  harnessRun = runOf(state.openTurn ?? state.pending[0]);
2012
- harness = await agent.openHarness(this.sessionId, store, harnessRun, ready);
2143
+ const opening = agent.openHarness(this.sessionId, store, harnessRun, ready);
2144
+ // A failed backstop does not wait out a slow open: its sender fails
2145
+ // the prompt once this worker stops. The late engine closes in the
2146
+ // background, and the agent's close() waits for it.
2147
+ if (backstopSent && !(await Promise.race([backstopSent, opening.then(() => true, () => true)]))) {
2148
+ agent.track(opening.then((late) => late.close()).catch(() => {}));
2149
+ await ready;
2150
+ return broken();
2151
+ }
2152
+ harness = await opening;
2013
2153
  } catch (error) {
2014
2154
  // The image store failed to answer while the engine read the
2015
2155
  // session's images: as when the log can't be read, the delivery
@@ -2026,6 +2166,14 @@ class SessionWorker {
2026
2166
  agent.emit("session.error", { sessionId: this.sessionId, error: errorSummary(error) });
2027
2167
  return "done";
2028
2168
  }
2169
+ // The opener is inert until its own backstop succeeds. A displaced
2170
+ // opener closes without taking a turn or writing anything.
2171
+ if (backstopSent && !(await backstopSent)) {
2172
+ agent.track(harness.close().catch(() => {}));
2173
+ harness = null;
2174
+ await ready;
2175
+ return broken();
2176
+ }
2029
2177
  const yieldAt = deadline === null ? undefined : Math.max(1, deadline - this.backend.reserveMs);
2030
2178
  for (;;) {
2031
2179
  if (failed) return broken();
@@ -2645,6 +2793,15 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2645
2793
  let closed = false;
2646
2794
  const workers = new Map();
2647
2795
  const active = new Set();
2796
+ // Session ids this process minted whose first prompt has not yet tried
2797
+ // to create their run, newest kept. Only a hint that the run may not
2798
+ // exist: whether it did is what that prompt's own write shows.
2799
+ const minted = new Set();
2800
+ const mint = (id) => {
2801
+ minted.add(id);
2802
+ if (minted.size > maxMintedIds) minted.delete(minted.values().next().value);
2803
+ return id;
2804
+ };
2648
2805
  const backend = () => {
2649
2806
  if (closed) return Promise.reject(new Error(`${label} is closed`));
2650
2807
  backendPromise ??= (async () => {
@@ -2687,17 +2844,27 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2687
2844
  const member = { open: backend, durability: durabilityOf, definition: definitionOf(harnessOptions), store: durability ? storeOf(durability) : unresolved, defaults: durability ? null : unresolved };
2688
2845
  claimName(agentName, member);
2689
2846
  const registered = joinGroup(agentsByName, agentName, member);
2690
- // One delivery of `message` to the session's worker here: from the
2691
- // queue, with `delivery` naming the queue's message and attempt, or with
2692
- // `inRequest` from the request that took the prompt.
2693
- const receive = (created, message, delivery, { inRequest = false, handoff = null } = {}) => {
2694
- let worker = workers.get(message.sessionId);
2847
+ // The session's worker here, kept until as many letGo() calls.
2848
+ const hold = (created, sessionId) => {
2849
+ let worker = workers.get(sessionId);
2695
2850
  if (!worker) {
2696
- worker = new SessionWorker(agent, created, message.sessionId);
2697
- workers.set(message.sessionId, worker);
2851
+ worker = new SessionWorker(agent, created, sessionId);
2852
+ workers.set(sessionId, worker);
2698
2853
  }
2699
2854
  worker.deliveries += 1;
2700
- let work = worker.consume(message, delivery, handoff);
2855
+ return worker;
2856
+ };
2857
+ const letGo = (worker, sessionId) => {
2858
+ worker.deliveries -= 1;
2859
+ // A worker lives while a delivery runs it.
2860
+ if (worker.deliveries === 0 && !worker.running && workers.get(sessionId) === worker) workers.delete(sessionId);
2861
+ };
2862
+ // One delivery of `message` to the session's worker here: from the
2863
+ // queue, with `delivery` naming the queue's message and attempt, or with
2864
+ // `inRequest` from the request that took the prompt.
2865
+ const receive = (created, message, delivery, { inRequest = false, handoff = null, backstop = null } = {}) => {
2866
+ const worker = hold(created, message.sessionId);
2867
+ let work = worker.consume(message, delivery, handoff, backstop);
2701
2868
  if (inRequest) {
2702
2869
  // No queue delivered this message, so none delivers it again: a turn
2703
2870
  // that must go on later continues in a fresh message. One whose
@@ -2718,9 +2885,7 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2718
2885
  emit("session.error", { sessionId: message.sessionId, error: errorSummary(error) });
2719
2886
  }).finally(() => {
2720
2887
  active.delete(work);
2721
- worker.deliveries -= 1;
2722
- // A worker lives while a delivery runs it.
2723
- if (worker.deliveries === 0 && !worker.running && workers.get(message.sessionId) === worker) workers.delete(message.sessionId);
2888
+ letGo(worker, message.sessionId);
2724
2889
  });
2725
2890
  return work;
2726
2891
  };
@@ -2741,14 +2906,17 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2741
2906
  let id = requestedId ?? null;
2742
2907
  let seeded = seed === undefined || requestedId !== undefined;
2743
2908
  const resolved = backend().then((created) => {
2744
- id ??= created.newSessionId();
2909
+ id ??= mint(created.newSessionId());
2745
2910
  if (!created.validSessionId(id)) throw new TypeError(`${created.name} durability cannot hold session id ${JSON.stringify(id)}`);
2746
2911
  return { created, id };
2747
2912
  });
2748
2913
  void resolved.catch(() => {});
2749
2914
  // With `inRequest`, a prompt runs here, in the request that took it,
2750
2915
  // where the durability allows (see `runInRequest`).
2751
- const send = async (message, { inRequest = false } = {}) => {
2916
+ // `fresh` is the log whose own write just created the session's run, so
2917
+ // the log is empty without reading it. That object, not a new one, goes
2918
+ // on: it holds what the creation showed of the log.
2919
+ const send = async (message, { inRequest = false, fresh = null } = {}) => {
2752
2920
  const { created, id: sessionId } = await resolved;
2753
2921
  const extra = {
2754
2922
  ...(seeded ? {} : { seed }),
@@ -2762,35 +2930,64 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2762
2930
  // a turn runs or as whom (`tests/tla/sdk/TenantIsolation.tla`
2763
2931
  // OnlySentRuns), and a queue that ends with the process loses no
2764
2932
  // input.
2765
- const log = await created.session(sessionId);
2766
- const read = await log.read();
2767
- const newestSeen = log.newest?.();
2768
- const appended = await recordMessage(log, foldSessionLog(read), full);
2769
- const queued = queuedMessage(full, created.store);
2770
- const idempotencyKey = `${sessionId}:${message.type}:${message.messageId}`;
2771
- if (inRequest && created.runInRequest) {
2772
- await created.refreshDeadline?.();
2773
- const deadline = deliveryDeadline(created);
2774
- if (deadline !== null) {
2775
- // This request runs the turn, and the queue keeps the message as
2776
- // a backstop it delivers only after the request's deadline, by
2777
- // when the request has stopped. The lease names the backstop, so
2778
- // if the request died, the backstop takes the session over and
2779
- // continues the turn.
2780
- const delaySeconds = Math.ceil(Math.max(0, deadline - Date.now()) / 1000) + inlineBackstopMarginSeconds;
2781
- const { messageId: backstop } = await created.queue.send(queued, { idempotencyKey, delaySeconds }) ?? {};
2782
- seeded = true;
2783
- const delivery = backstop ? { messageId: backstop, attempt: 0 } : null;
2784
- // receive() reports a failure to the host; the backstop retries it.
2785
- const entries = handoffEntries(read, newestSeen, appended);
2786
- const handoff = entries ? { log, entries } : null;
2787
- const work = receive(created, queued, delivery, { inRequest: true, handoff }).then(() => {}, () => {});
2788
- created.waitUntil?.(work);
2789
- return;
2933
+ const log = fresh ?? await created.session(sessionId);
2934
+ const inline = inRequest && created.runInRequest;
2935
+ // Held from before the read until receive() takes over, so a turn
2936
+ // run here meanwhile is counted on the worker the handoff goes to,
2937
+ // not on one dropped in between (see consume).
2938
+ const worker = inline ? hold(created, sessionId) : null;
2939
+ try {
2940
+ // Taken before this message is written, so a turn here that could
2941
+ // have run it counts, also when a fresh log is not read at all.
2942
+ const seen = worker?.mark() ?? null;
2943
+ // Fresh: the caller's own write just created the run, so its log
2944
+ // held no entry, as a read before that write would have shown.
2945
+ const read = fresh ? [] : await log.read();
2946
+ const appended = await recordMessage(log, foldSessionLog(read), full);
2947
+ // Taken after the writes, which pass it over a run they created.
2948
+ const newestSeen = log.newest?.();
2949
+ const queued = queuedMessage(full, created.store);
2950
+ const idempotencyKey = `${sessionId}:${message.type}:${message.messageId}`;
2951
+ if (inline) {
2952
+ await created.refreshDeadline?.();
2953
+ const deadline = deliveryDeadline(created);
2954
+ if (deadline !== null) {
2955
+ // This request runs the turn, and the queue keeps the message as
2956
+ // a backstop it delivers only after the request's deadline, by
2957
+ // when the request has stopped. The lease names the backstop, so
2958
+ // if the request died, the backstop takes the session over and
2959
+ // continues the turn.
2960
+ const delaySeconds = Math.ceil(Math.max(0, deadline - Date.now()) / 1000) + inlineBackstopMarginSeconds;
2961
+ const backstop = (async () => {
2962
+ const { messageId: backstopId } = await created.queue.send(queued, { idempotencyKey, delaySeconds }) ?? {};
2963
+ return backstopId ? { messageId: backstopId, attempt: 0 } : null;
2964
+ })();
2965
+ void backstop.catch(() => {});
2966
+ // The worker opens its engine while the backstop is sent; its
2967
+ // lease, its writes and its first model request wait for the
2968
+ // send (see claim). receive() reports a failure to the host; the
2969
+ // backstop retries it.
2970
+ const entries = handoffEntries(read, newestSeen, appended);
2971
+ const handoff = entries ? { log, entries, seen } : null;
2972
+ const work = receive(created, queued, null, { inRequest: true, handoff, backstop }).then(() => {}, () => {});
2973
+ created.waitUntil?.(work);
2974
+ try {
2975
+ await backstop;
2976
+ } catch (error) {
2977
+ // The worker has stopped, having stored nothing, before the
2978
+ // caller hears: a retry finds no worker here, as if none ran.
2979
+ await work;
2980
+ throw error;
2981
+ }
2982
+ seeded = true;
2983
+ return;
2984
+ }
2790
2985
  }
2986
+ await created.queue.send(queued, { idempotencyKey });
2987
+ seeded = true;
2988
+ } finally {
2989
+ if (worker) letGo(worker, sessionId);
2791
2990
  }
2792
- await created.queue.send(queued, { idempotencyKey });
2793
- seeded = true;
2794
2991
  };
2795
2992
  // Settles once the latest prompt from this session object with images
2796
2993
  // to store is stored or has failed; null while none has. Each later
@@ -2812,20 +3009,26 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2812
3009
  try {
2813
3010
  const read = input === undefined || ready === null || aborted() ? input : await ready();
2814
3011
  const message = { type, messageId, ...(input === undefined ? {} : { input: read }) };
3012
+ const { created, id: sessionId } = await resolved;
3013
+ const log = await created.session(sessionId);
3014
+ // The first prompt to a session id this process minted creates
3015
+ // its run before anything reads it. When that write made the
3016
+ // run, the log held no entry and the stream no line: what this
3017
+ // turn would read is known, retried or not, as reads just before
3018
+ // the write would have returned it.
3019
+ const fresh = type === "prompt" && !aborted() && minted.delete(sessionId) && typeof log.fresh === "function" && await log.fresh();
2815
3020
  const queue = () => {
2816
3021
  if (progress) progress.sent = true;
2817
- return send(message, { inRequest: type === "prompt" });
3022
+ return send(message, { inRequest: type === "prompt", fresh: fresh ? log : null });
2818
3023
  };
2819
- const { created, id: sessionId } = await resolved;
2820
- const log = await created.session(sessionId);
2821
3024
  let attaching = type === "resume";
2822
- let from = null;
3025
+ let from = fresh ? 0 : null;
2823
3026
  // The viewer's start is read before the message is queued, so
2824
3027
  // every line this message's own turn writes comes after it, and
2825
3028
  // an end for this id before it can only be from a turn this id
2826
3029
  // already ran.
2827
- if (retried) from = await log.ui.length();
2828
- if (retried && !aborted()) {
3030
+ if (retried && !fresh) from = await log.ui.length();
3031
+ if (retried && !fresh && !aborted()) {
2829
3032
  // The log, for that answer, is read alongside the one queue
2830
3033
  // send.
2831
3034
  const [state] = await Promise.all([
@@ -2841,7 +3044,7 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
2841
3044
  if (outcome.unsure) return { log, from, attaching: true, endWithinMs: retryEndWaitMs, unsure: true };
2842
3045
  return { log, from, attaching: outcome.follow === true };
2843
3046
  }
2844
- if (retried) {
3047
+ if (retried && !fresh) {
2845
3048
  // A turn this id already ran answers from the log, without
2846
3049
  // queueing it again. The viewer's start was read first, so a
2847
3050
  // turn that ends after it has its end in view.
@@ -3145,7 +3348,7 @@ export function createDurableAgentFactory({ harness, defaultDurability, defaultS
3145
3348
  },
3146
3349
  /** An id for a new session, before its first prompt. */
3147
3350
  async newSessionId() {
3148
- return (await backend()).newSessionId();
3351
+ return mint((await backend()).newSessionId());
3149
3352
  },
3150
3353
  /** The agent's name: its sessions' turns run only on agents with this name. */
3151
3354
  get name() {
package/fetch-cleanup.js CHANGED
@@ -1,6 +1,14 @@
1
1
  export const cleanupTimeoutMs = 100;
2
2
  export const cleanupByteLimit = 64 * 1024;
3
3
 
4
+ // A response's Retry-After in whole seconds, read as native fx reads it
5
+ // (digits only), or -1 when it has none. Both hosts pass it to fx.
6
+ export function retryAfterSeconds(headers) {
7
+ const value = headers.get("retry-after")?.trim();
8
+ if (!value || !/^\d+$/.test(value)) return -1;
9
+ return Math.min(Number(value), 0x7fffffff);
10
+ }
11
+
4
12
  // Policy only; the host owns the reader, clock, cancellation and teardown.
5
13
  export function fetchCleanupAction(state, maxAge = cleanupTimeoutMs, maxBytes = cleanupByteLimit) {
6
14
  if (state.eof) return "done";
package/fx-core.wasm CHANGED
Binary file
package/fx-term.wasm CHANGED
Binary file
package/node.js CHANGED
@@ -7,7 +7,7 @@ import { isAbsolute, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { bundledAssetUrl } from "./bundled-asset.js";
9
9
  import { CoreOutput } from "./core-output.js";
10
- import { fetchCleanupAction, cleanupTimeoutMs, cleanupByteLimit } from "./fetch-cleanup.js";
10
+ import { fetchCleanupAction, cleanupTimeoutMs, cleanupByteLimit, retryAfterSeconds } from "./fetch-cleanup.js";
11
11
  import { createDurableAgentFactory, memory, wakeHandler } from "./durable.js";
12
12
  import { fxHarness } from "./fx-harness.js";
13
13
  import {
@@ -471,7 +471,11 @@ function createNativeCoreRuntime(addon, options) {
471
471
  });
472
472
  const response = await wait(responseTask);
473
473
  if (settled && !state.consumed) throw new DOMException("Aborted", "AbortError");
474
- const started = settled ? fetchOperationStale : addon.startCoreFetchResponse(core, state.handle, response.status);
474
+ const started = settled
475
+ ? fetchOperationStale
476
+ : typeof addon.startCoreFetchResponseWithRetryAfter === "function"
477
+ ? addon.startCoreFetchResponseWithRetryAfter(core, state.handle, response.status, retryAfterSeconds(response.headers))
478
+ : addon.startCoreFetchResponse(core, state.handle, response.status);
475
479
  if (started !== fetchOperationApplied && started !== fetchOperationStale) throw new Error(`invalid native fetch start result ${started}`);
476
480
  state.active = started === fetchOperationApplied;
477
481
  reader = response.body?.getReader();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfx",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -97,9 +97,9 @@
97
97
  },
98
98
  "license": "Apache-2.0",
99
99
  "optionalDependencies": {
100
- "@libfx/libfx-linux-x64-gnu": "0.0.16",
101
- "@libfx/libfx-linux-arm64-gnu": "0.0.16",
102
- "@libfx/libfx-darwin-x64": "0.0.16",
103
- "@libfx/libfx-darwin-arm64": "0.0.16"
100
+ "@libfx/libfx-linux-x64-gnu": "0.0.17",
101
+ "@libfx/libfx-linux-arm64-gnu": "0.0.17",
102
+ "@libfx/libfx-darwin-x64": "0.0.17",
103
+ "@libfx/libfx-darwin-arm64": "0.0.17"
104
104
  }
105
105
  }
package/types/libfx.d.cts CHANGED
@@ -46,7 +46,7 @@ export interface FxModelOptions {
46
46
  effort?: FxEffort;
47
47
  /** Enables the fast lane for models that advertise one. */
48
48
  fast?: boolean;
49
- /** Requests Ultra mode, the higher-cost service tier, for models whose metadata advertises it. Disables Fast. */
49
+ /** Requests Ultrafast mode, the higher-cost service tier, for models whose metadata advertises it. Disables Fast. */
50
50
  ultrafast?: boolean;
51
51
  }
52
52
 
@@ -261,8 +261,8 @@ export interface FxUsage {
261
261
  reasoningTokens?: number;
262
262
  }
263
263
 
264
- /** Why an engine turn ended. */
265
- export type FxStopReason = "end_turn" | "max_output_tokens" | "max_model_turns" | "refused" | "cancelled";
264
+ /** Why an engine turn ended. `yielded`: it stopped at its `yieldAt` and stays open for `resume()`. */
265
+ export type FxStopReason = "end_turn" | "max_output_tokens" | "max_model_turns" | "refused" | "cancelled" | "yielded";
266
266
 
267
267
  // ---------------------------------------------------------------------------
268
268
  // Diagnostics
@@ -304,7 +304,8 @@ export type FxEngineEvent = FxEventBase & (
304
304
  | { type: "permission.request"; request: unknown }
305
305
  | { type: "permission.resolve"; optionId: unknown }
306
306
  | { type: "journal.open"; events: number; checkpoint: boolean; turns: unknown; resumable: boolean }
307
- | { type: "journal.append"; events: number }
307
+ /** `barrier`: the record's `barrier`, as `Persistence.append` receives it. */
308
+ | { type: "journal.append"; events: number; barrier: boolean }
308
309
  | { type: "journal.error"; error: string }
309
310
  | { type: "checkpoint.save"; through: string; bytes: number }
310
311
  | { type: "checkpoint.error"; error: string; message: string }
@@ -423,7 +424,7 @@ export type FxHookEvent = FxHookEnvelope & (
423
424
  | { type: "mcp.start"; data: { operationId: number | string; callId: string | null; server: string | null; tool: string; transport: string | null } }
424
425
  | { type: "mcp.request"; data: { operationId: number | string; requestId: number | string; requestIdKind: "jsonrpc" | "engine" | "correlation" } }
425
426
  | { type: "mcp.end"; data: { operationId: number | string; status: "success" | "failure" | "cancelled" | "input_required" | "unavailable" | "unknown"; error: string | null } }
426
- | { type: "delegation"; data: { callId: string; childSessionId: string; workId: string; operationId: string | null; phase: "dispatched" | "completed" | "yielded" | "replayed" | "failed" | "cancelled" | "feedback_queued" | "feedback_applied" | "feedback_not_applied" } }
427
+ | { type: "delegation"; data: { callId: string; childSessionId: string; workId: string; operationId: string | null; phase: "dispatched" | "completed" | "replayed" | "failed" | "cancelled" | "feedback_queued" | "feedback_applied" | "feedback_not_applied" } }
427
428
  /** Emitted only after append resolves and its cursor validates. */
428
429
  | { type: "journal.commit"; data: { cursor: string; firstSeq: number | null; lastSeq: number | null; eventCount: number } }
429
430
  | { type: "journal.restore"; data: { cursor: string | null; lastSeq: number; resumable: boolean; openTurnId: string | null; lastTurnId: string | null } }
@@ -669,6 +670,12 @@ export interface FxCustomProviderModel {
669
670
  maxOutputTokens?: number;
670
671
  supportsToolUse?: boolean;
671
672
  supportsVision?: boolean;
673
+ /** The request field that carries the output limit. Defaults to `"max_tokens"`. */
674
+ outputLimitField?: "max_tokens" | "max_completion_tokens";
675
+ /** Fields sent in the body of every request for the model, such as `{ temperature: 0.2 }`. A field fx writes itself, such as `messages`, is refused. */
676
+ request?: Record<string, unknown>;
677
+ /** The effort levels the model offers, in order, each with the request fields that choose it, such as `{ high: { reasoning_effort: "high" } }`. A level the model does not list is not sent. */
678
+ reasoningEfforts?: Record<string, Record<string, unknown>>;
672
679
  }
673
680
 
674
681
  /** A server that speaks OpenAI Chat Completions, such as a local model server or a router. */
@@ -679,6 +686,12 @@ export interface FxCustomProviderDefinition {
679
686
  baseUrl: string;
680
687
  /** The models it serves, by id. */
681
688
  models?: Record<string, FxCustomProviderModel>;
689
+ /** Fields for every model, including one `models` does not list, where the model sets none itself. */
690
+ modelDefaults?: FxCustomProviderModel;
691
+ /** The model that names a session from its first prompt. Without it, a session keeps the title derived from the prompt. */
692
+ titleModel?: string;
693
+ /** The model that reads images for a model that cannot. Without it, the provider has no image fallback. */
694
+ visionModel?: string;
682
695
  }
683
696
 
684
697
  /** A custom provider's API key. */
@@ -796,8 +809,8 @@ export interface FxAccepted {
796
809
  /** How a durable turn ended. */
797
810
  export interface FxTurnResult {
798
811
  messageId: string;
799
- /** `error` when the turn failed, with the reason in `error`; `unknown` when the process that ran it stopped before writing how it ended. */
800
- stopReason: FxStopReason | "error" | "unknown";
812
+ /** `error` when the turn failed, with the reason in `error`; `unknown` when the process that ran it stopped before writing how it ended. A yielded turn continues, so it never ends `yielded`. */
813
+ stopReason: Exclude<FxStopReason, "yielded"> | "error" | "unknown";
801
814
  /** Absent when the result is a failure read from the session's log rather than from the turn's end. */
802
815
  usage?: FxUsage;
803
816
  error?: FxErrorSummary;
@@ -848,7 +861,7 @@ export type FxStreamLine = (
848
861
  | {
849
862
  type: "turn_end";
850
863
  messageId: string;
851
- stopReason: FxStopReason | "error" | "unknown";
864
+ stopReason: Exclude<FxStopReason, "yielded"> | "error" | "unknown";
852
865
  usage?: FxUsage;
853
866
  error?: FxErrorSummary;
854
867
  repeated?: true;
package/wasm-runtime.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The WebAssembly host: WASI, fx's host imports, and starting an fx
2
2
  // WebAssembly module. Only the WebAssembly backends load it.
3
3
  import { CoreOutput, maxCoreMessageBytes } from "./core-output.js";
4
+ import { retryAfterSeconds } from "./fetch-cleanup.js";
4
5
  import {
5
6
  cancelResponseBody,
6
7
  decoder,
@@ -352,6 +353,11 @@ function createRuntime(options) {
352
353
  ready ? settled() : 0);
353
354
  }
354
355
 
356
+ function streamRetryAfter(handle) {
357
+ const response = streams.get(handle)?.response;
358
+ return response ? retryAfterSeconds(response.headers) : -1;
359
+ }
360
+
355
361
  function streamNext(handle, outPtr, outCap) {
356
362
  const state = streams.get(handle);
357
363
  if (!state) return -1;
@@ -930,6 +936,7 @@ function createRuntime(options) {
930
936
  fx_http_stream_status: new WebAssembly.Suspending(streamStatus),
931
937
  fx_http_stream_next: new WebAssembly.Suspending(streamNext),
932
938
  fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(abortReason); streams.delete(handle); },
939
+ fx_http_stream_retry_after: streamRetryAfter,
933
940
  fx_http_request: new WebAssembly.Suspending(httpRequest),
934
941
  fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
935
942
  fx_journal_flush: new WebAssembly.Suspending(journalFlush),