libfx 0.0.13 → 0.0.14-dev.1534.g6afa7c33dac5

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.
@@ -0,0 +1,12 @@
1
+ export const cleanupTimeoutMs = 100;
2
+ export const cleanupByteLimit = 64 * 1024;
3
+
4
+ // Policy only; the host owns the reader, clock, cancellation and teardown.
5
+ export function fetchCleanupAction(state, maxAge = cleanupTimeoutMs, maxBytes = cleanupByteLimit) {
6
+ if (state.eof) return "done";
7
+ if (state.canceled || state.failed) return "abort";
8
+ if (state.consumed) {
9
+ return state.age >= maxAge || state.discarded >= maxBytes ? "abort" : "drain";
10
+ }
11
+ return state.alive && state.active && !state.closing ? "forward" : "abort";
12
+ }
package/fx-core.wasm CHANGED
Binary file
package/fx-harness.js ADDED
@@ -0,0 +1,220 @@
1
+ // fx as a harness of the durable core in durable.js: `createFxAgent` is that
2
+ // core running fx engines. Everything the core needs to know about fx is
3
+ // here; the core itself knows only the harness contract.
4
+ import { coreAnswered, engineInternals, journalMarksWanted, normalizeInstructions, normalizeModelChoice } from "./fx-sdk.js";
5
+ import { createCredentialMemory, credentialMemory } from "./subscription.js";
6
+
7
+ const encoder = new TextEncoder();
8
+ // Every worker reads each record since the session's last checkpoint, so a
9
+ // durable session checkpoints far sooner than the engine's default.
10
+ const durableCheckpointAfterBytes = 64 * 1024;
11
+
12
+ /**
13
+ * The fx harness for `createDurableAgentFactory`. `createEngine(options)`
14
+ * returns a `createFxEngine` session, and `defaultApiKey(durability)` the
15
+ * credential when the caller passes none.
16
+ */
17
+ export function fxHarness({ createEngine, defaultApiKey = async () => undefined }) {
18
+ return (options) => {
19
+ const { checkpoint, ...engineOptions } = options;
20
+ // `env` is refused here as well as by the engine, so the error names
21
+ // createFxAgent() when the agent is created, not when a session opens.
22
+ for (const name of ["env", "persistence", "checkpointAfterBytes", "sessionId", "inputsDurable", "toolContext"]) {
23
+ if (Object.hasOwn(engineOptions, name)) throw new TypeError(`createFxAgent() does not accept ${name}`);
24
+ }
25
+ const idempotent = idempotentTools(engineOptions.tools);
26
+ // One for all of this agent's sessions, so they refresh an account once.
27
+ const credentials = createCredentialMemory();
28
+ return {
29
+ ...(checkpoint === undefined ? {} : { seed: snapshotOf(checkpointBytesOf(checkpoint)) }),
30
+ settings: sessionSettings,
31
+ async open({ sessionId, store, context, settings, durability, ready }) {
32
+ const apiKey = engineOptions.apiKey ?? await defaultApiKey(durability);
33
+ const engine = await createEngine({
34
+ ...withSettings(engineOptions, settings),
35
+ ...(apiKey === undefined ? {} : { apiKey }),
36
+ ...(ready === undefined || engineOptions.tools === undefined ? {} : { tools: claimedTools(engineOptions.tools, ready) }),
37
+ sessionId,
38
+ [credentialMemory]: credentials,
39
+ // The core folds the session from the marks on each record.
40
+ persistence: { ...store, [journalMarksWanted]: true },
41
+ inputsDurable: true,
42
+ // A turn's end saves a checkpoint once the records since the last
43
+ // one reach this many bytes, so a short turn writes none and a
44
+ // worker reads back little; where a turn yields, the core saves
45
+ // one itself before it lets the session go.
46
+ checkpointAfterBytes: durableCheckpointAfterBytes,
47
+ ...(context === undefined || context === null ? {} : { toolContext: context }),
48
+ });
49
+ return fxSession(engine, idempotent);
50
+ },
51
+ };
52
+ };
53
+ }
54
+
55
+ // What `agent.session(id, { model, instructions })` sets for the turns it
56
+ // starts, checked as the engine checks it and copied, since the core stores
57
+ // it with each prompt; undefined when it sets neither.
58
+ function sessionSettings({ model, instructions } = {}) {
59
+ if (model !== undefined) {
60
+ if (typeof model !== "string" && (model === null || typeof model !== "object" || Array.isArray(model))) {
61
+ throw new TypeError("session model must be a model id or a model object");
62
+ }
63
+ normalizeModelChoice({ model });
64
+ }
65
+ if (instructions !== undefined) normalizeInstructions(instructions);
66
+ if (model === undefined && instructions === undefined) return undefined;
67
+ return JSON.parse(JSON.stringify({ model, instructions }));
68
+ }
69
+
70
+ // The agent's engine options with a session's settings over them. A
71
+ // session's model replaces the agent's whole model choice, its effort and
72
+ // fast options included.
73
+ function withSettings(engineOptions, settings) {
74
+ if (!settings) return engineOptions;
75
+ const { model: _model, effort: _effort, fast: _fast, ultrafast: _ultrafast, ...rest } = engineOptions;
76
+ return {
77
+ ...(settings.model === undefined ? engineOptions : { ...rest, model: settings.model }),
78
+ ...(settings.instructions === undefined ? {} : { instructions: settings.instructions }),
79
+ };
80
+ }
81
+
82
+ // The engine reports a fenced write as the cause of the append that failed;
83
+ // the core knows a fence by its own `FX_FENCED` code.
84
+ const unwrapFence = (error) => (error?.cause?.code === "FX_FENCED" ? error.cause : error);
85
+
86
+ // How long a failed turn waits to learn whether its core exited.
87
+ const exitWaitMs = 1000;
88
+
89
+ // A failed turn's error as the core reads it: a fence; a turn the core
90
+ // ended with an error; or, when the core exited under it, a harness that
91
+ // stopped, which the next delivery replaces.
92
+ async function turnError(error, exited) {
93
+ const unwrapped = unwrapFence(error);
94
+ if (unwrapped?.code === "FX_FENCED" || unwrapped?.[coreAnswered]) return unwrapped;
95
+ let timer;
96
+ const gone = await Promise.race([
97
+ exited.then(() => true, () => true),
98
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), exitWaitMs); }),
99
+ ]).finally(() => clearTimeout(timer));
100
+ if (!gone) return unwrapped;
101
+ return Object.assign(new Error(String(unwrapped?.message ?? unwrapped), { cause: unwrapped }), { code: "FX_HARNESS_STOPPED" });
102
+ }
103
+
104
+ function fxTurn(turn, exited) {
105
+ const result = turn.result.catch(async (error) => { throw await turnError(error, exited); });
106
+ void result.catch(() => {});
107
+ return {
108
+ result,
109
+ steer: (text, id) => turn.steer(text, id),
110
+ endForSteer: () => turn[engineInternals]?.endForSteer(),
111
+ cancel: (options) => turn.cancel(options),
112
+ async *[Symbol.asyncIterator]() {
113
+ try {
114
+ for await (const event of turn) yield event;
115
+ } catch (error) {
116
+ throw await turnError(error, exited);
117
+ }
118
+ },
119
+ };
120
+ }
121
+
122
+ // A turn the engine would not start because of its input: it ends at once
123
+ // with an error and stores nothing.
124
+ function refusedTurn(error) {
125
+ return {
126
+ result: Promise.resolve({ stopReason: "error", usage: {}, error: { name: error?.name ?? "Error", message: String(error?.message ?? error) } }),
127
+ steer: async () => {},
128
+ endForSteer: async () => {},
129
+ cancel() {},
130
+ async *[Symbol.asyncIterator]() {},
131
+ };
132
+ }
133
+
134
+ function fxSession(engine, idempotent) {
135
+ const internals = engine[engineInternals];
136
+ return {
137
+ prompt: (input, options) => {
138
+ try {
139
+ return fxTurn(engine.prompt(input, options), internals.exited);
140
+ } catch (error) {
141
+ // The engine refuses an input it cannot take with a TypeError or a
142
+ // RangeError. Any other throw means it cannot run a turn at all.
143
+ if (error instanceof TypeError || error instanceof RangeError) return refusedTurn(error);
144
+ throw error;
145
+ }
146
+ },
147
+ // A call left running reruns only when running it twice is safe, and
148
+ // with `rerun: false` not even then. Any other call is never run again:
149
+ // the model is told it may have partly run, and the turn goes on.
150
+ resume: ({ rerun = true, ...options } = {}) => fxTurn(engine.resume({ ...options, onAmbiguous: (call) => (rerun && idempotent.has(call.name) ? "rerun" : undefined) }), internals.exited),
151
+ get openTurn() {
152
+ return internals.openTurn;
153
+ },
154
+ settled: () => internals.settled().catch((error) => { throw unwrapFence(error); }),
155
+ saveCheckpoint: () => internals.checkpoint(),
156
+ exportCheckpoint: () => engine.checkpoint(),
157
+ close: () => engine.close(),
158
+ };
159
+ }
160
+
161
+ function toolEntries(tools) {
162
+ if (Array.isArray(tools)) return tools;
163
+ if (tools && typeof tools === "object") return Object.entries(tools).map(([name, tool]) => ({ ...tool, name: tool?.name ?? name }));
164
+ return [];
165
+ }
166
+
167
+ // The tools as a worker runs them: a turn can start before the worker's
168
+ // claim on the session lands, and no tool runs before it does.
169
+ function claimedTools(tools, ready) {
170
+ const claimed = (tool) => (typeof tool?.execute !== "function" ? tool : {
171
+ ...tool,
172
+ async execute(...args) {
173
+ if (!(await ready)) throw new Error("this worker's claim on the session failed");
174
+ return tool.execute(...args);
175
+ },
176
+ });
177
+ if (Array.isArray(tools)) return tools.map(claimed);
178
+ if (tools && typeof tools === "object") return Object.fromEntries(Object.entries(tools).map(([name, tool]) => [name, claimed(tool)]));
179
+ return tools;
180
+ }
181
+
182
+ // The tools whose cut-off calls run again. Every tool the host runs must say
183
+ // whether that is safe, so the choice is never a default: `idempotent: true`
184
+ // when running a call twice is harmless, `false` when it is not. A tool that
185
+ // is not an object is left for the engine to refuse.
186
+ function idempotentTools(tools) {
187
+ const names = new Set();
188
+ for (const [index, tool] of toolEntries(tools).entries()) {
189
+ if (!tool || typeof tool !== "object" || tool.providerExecuted === true) continue;
190
+ if (typeof tool.idempotent !== "boolean") {
191
+ const name = typeof tool.name === "string" ? tool.name : index;
192
+ throw new TypeError(`tool ${name} must set idempotent: true if running a call twice is safe, or idempotent: false if it is not`);
193
+ }
194
+ if (tool.idempotent && typeof tool.name === "string") names.add(tool.name);
195
+ }
196
+ return names;
197
+ }
198
+
199
+ function checkpointBytesOf(value) {
200
+ if (value instanceof Uint8Array) return value;
201
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
202
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
203
+ throw new TypeError("checkpoint must be bytes");
204
+ }
205
+
206
+ // A kernel checkpoint as the snapshot a session's log starts from: `FXSN`,
207
+ // version 1, then the event it covers through, the turn after it, and no
208
+ // turn id. The seed counts as the session's first event, so the engine's
209
+ // own events start at seq 2.
210
+ function snapshotOf(checkpoint) {
211
+ const bytes = new Uint8Array(4 + 1 + 8 + 8 + 1 + checkpoint.byteLength);
212
+ bytes.set(encoder.encode("FXSN"), 0);
213
+ bytes[4] = 1;
214
+ const view = new DataView(bytes.buffer);
215
+ view.setBigUint64(5, 1n, true);
216
+ view.setBigUint64(13, 1n, true);
217
+ bytes[21] = 0;
218
+ bytes.set(checkpoint, 22);
219
+ return bytes;
220
+ }