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 +4 -0
- package/custom-provider.js +53 -21
- package/durable.js +274 -71
- package/fetch-cleanup.js +8 -0
- package/fx-core.wasm +0 -0
- package/fx-term.wasm +0 -0
- package/node.js +6 -2
- package/package.json +5 -5
- package/types/libfx.d.cts +21 -8
- package/wasm-runtime.js +7 -0
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:
|
package/custom-provider.js
CHANGED
|
@@ -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
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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)
|
|
1697
|
-
|
|
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
|
|
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.
|
|
1766
|
-
//
|
|
1767
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2691
|
-
|
|
2692
|
-
|
|
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,
|
|
2697
|
-
workers.set(
|
|
2851
|
+
worker = new SessionWorker(agent, created, sessionId);
|
|
2852
|
+
workers.set(sessionId, worker);
|
|
2698
2853
|
}
|
|
2699
2854
|
worker.deliveries += 1;
|
|
2700
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
const
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
const
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
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
|
|
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.
|
|
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.
|
|
101
|
-
"@libfx/libfx-linux-arm64-gnu": "0.0.
|
|
102
|
-
"@libfx/libfx-darwin-x64": "0.0.
|
|
103
|
-
"@libfx/libfx-darwin-arm64": "0.0.
|
|
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
|
|
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
|
-
|
|
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" | "
|
|
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),
|