libfx 0.0.14-dev.1534.g6afa7c33dac5 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -4
- package/chatgpt.js +2 -1
- package/durable/local.mjs +12 -12
- package/durable/vercel.mjs +1 -1
- package/durable/world.mjs +1 -1
- package/durable.js +229 -68
- package/fx-core.wasm +0 -0
- package/fx-sdk.js +82 -7
- package/fx-term.wasm +0 -0
- package/libfx.darwin-arm64.node +0 -0
- package/libfx.darwin-x64.node +0 -0
- package/libfx.linux-arm64.node +0 -0
- package/libfx.linux-x64.node +0 -0
- package/mcp.js +11 -2
- package/node.cjs +396 -48
- package/node.js +2 -0
- package/package.json +1 -1
- package/subscription.js +2 -0
- package/tool-observation.js +185 -0
- package/types/browser.d.ts +7 -0
- package/types/durable-world.d.ts +7 -1
- package/types/libfx.d.cts +129 -3
- package/types/mcp.d.ts +6 -1
- package/types/node.d.cts +7 -0
- package/types/wasm.d.ts +7 -0
package/fx-sdk.js
CHANGED
|
@@ -4,6 +4,7 @@ import { customProviderDefinition, customProviderSpec } from "./custom-provider.
|
|
|
4
4
|
import { grokSubscription } from "./grok.js";
|
|
5
5
|
import { createSubscriptionAuthorizer, credentialMemory } from "./subscription.js";
|
|
6
6
|
import { loadModule } from "./wasm-module.js";
|
|
7
|
+
import { toolObservation } from "./tool-observation.js";
|
|
7
8
|
|
|
8
9
|
// Providers that run on a user's plan, authorized by the host's credentials.
|
|
9
10
|
const subscriptionProviders = new Set(["codex", "grok"]);
|
|
@@ -150,6 +151,25 @@ export function normalizeAgentOptions(value) {
|
|
|
150
151
|
throw new TypeError("createFxEngine() options must be an object");
|
|
151
152
|
}
|
|
152
153
|
const options = { ...value };
|
|
154
|
+
if (options.hooks !== undefined) {
|
|
155
|
+
const hooks = options.hooks;
|
|
156
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks) || typeof hooks.onEvent !== "function") {
|
|
157
|
+
throw new TypeError("hooks must be an object with onEvent()");
|
|
158
|
+
}
|
|
159
|
+
if (hooks.onError !== undefined && typeof hooks.onError !== "function") throw new TypeError("hooks.onError must be a function");
|
|
160
|
+
if (hooks.captureContent !== undefined && typeof hooks.captureContent !== "boolean") throw new TypeError("hooks.captureContent must be a boolean");
|
|
161
|
+
options.hooks = {
|
|
162
|
+
captureContent: hooks.captureContent === true,
|
|
163
|
+
onEvent: hooks.onEvent.bind(hooks),
|
|
164
|
+
...(hooks.onError === undefined ? {} : { onError: hooks.onError.bind(hooks) }),
|
|
165
|
+
...(hooks.parent === undefined ? {} : { parent: { ...hooks.parent } }),
|
|
166
|
+
};
|
|
167
|
+
if (options.hooks.parent !== undefined) {
|
|
168
|
+
for (const name of ["rootSessionId", "sessionId", "turnId", "callId"]) {
|
|
169
|
+
if (typeof options.hooks.parent[name] !== "string" || options.hooks.parent[name].length === 0 || options.hooks.parent[name].length > 1024) throw new TypeError(`hooks.parent.${name} must be a bounded identity`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
153
173
|
if (Object.hasOwn(options, "env")) {
|
|
154
174
|
throw new TypeError("createFxEngine() does not accept env; pass apiKey and model directly");
|
|
155
175
|
}
|
|
@@ -409,6 +429,7 @@ function checkpointSeq(bytes) {
|
|
|
409
429
|
|
|
410
430
|
// Reads what `persistence.load()` returns: the latest checkpoint, if any, and
|
|
411
431
|
// the records after it, as the events the core folds after that checkpoint.
|
|
432
|
+
// Modeled in `tests/tla/durable/SnapshotTail.tla`.
|
|
412
433
|
async function loadPersistence(persistence) {
|
|
413
434
|
const loaded = await persistence.load();
|
|
414
435
|
if (!loaded || typeof loaded !== "object") {
|
|
@@ -2067,7 +2088,10 @@ function toolSchemaHash(descriptors) {
|
|
|
2067
2088
|
}
|
|
2068
2089
|
|
|
2069
2090
|
// `tools` is an array of descriptors, or an object of descriptors keyed by
|
|
2070
|
-
// name.
|
|
2091
|
+
// name. What `idempotent` and `writes` mean after a crash and for calls that
|
|
2092
|
+
// run together is modeled in `tests/tla/durable/ToolReplay.tla`, whose
|
|
2093
|
+
// `replay: "safe"` is an idempotent tool that does not write, and in
|
|
2094
|
+
// `tests/tla/durable/ParallelBatch.tla`.
|
|
2071
2095
|
function normalizeHostTools(value) {
|
|
2072
2096
|
if (value === undefined) return { descriptors: [], executors: new Map() };
|
|
2073
2097
|
let entries;
|
|
@@ -2245,6 +2269,33 @@ export async function createFxEngine(options = {}) {
|
|
|
2245
2269
|
const pending = new Map();
|
|
2246
2270
|
let nextId = 1;
|
|
2247
2271
|
let sessionId = null;
|
|
2272
|
+
const hookScopes = new Map();
|
|
2273
|
+
// Observation never participates in effects, journal barriers, or retries.
|
|
2274
|
+
// Contain both throws and rejected promises, including an error handler's.
|
|
2275
|
+
const hookError = (error) => {
|
|
2276
|
+
try { Promise.resolve(options.hooks?.onError?.(error)).catch(() => {}); } catch {}
|
|
2277
|
+
};
|
|
2278
|
+
const hook = (event) => {
|
|
2279
|
+
if (!options.hooks) return;
|
|
2280
|
+
if (options.hooks.parent) event = {
|
|
2281
|
+
...event, rootSessionId: options.hooks.parent.rootSessionId,
|
|
2282
|
+
...(event.sessionId === sessionId && event.parentSessionId == null ? {
|
|
2283
|
+
parentSessionId: options.hooks.parent.sessionId, parentTurnId: options.hooks.parent.turnId, parentCallId: options.hooks.parent.callId,
|
|
2284
|
+
} : {}),
|
|
2285
|
+
};
|
|
2286
|
+
if (event.type === "tool.start") {
|
|
2287
|
+
const { data, type, ...scope } = event;
|
|
2288
|
+
hookScopes.set(`${event.sessionId}:${data.call.callId}`, scope);
|
|
2289
|
+
} else if (event.type === "tool.end") hookScopes.delete(`${event.sessionId}:${event.data.call.callId}`);
|
|
2290
|
+
else if (event.type === "turn.end") {
|
|
2291
|
+
for (const [key, scope] of hookScopes) if (scope.executionId === event.executionId) hookScopes.delete(key);
|
|
2292
|
+
} else if (event.type === "journal.error" && event.data.fenced) hookScopes.clear();
|
|
2293
|
+
try { Promise.resolve(options.hooks.onEvent(event)).catch(hookError); } catch (error) { hookError(error); }
|
|
2294
|
+
};
|
|
2295
|
+
const journalHook = (type, data) => hook({
|
|
2296
|
+
version: 1, type, sessionId, rootSessionId: sessionId, parentSessionId: null, sourceSessionId: null, workId: null, turnId: null, executionId: null, stepIndex: 0,
|
|
2297
|
+
timestamp: Date.now(), contentCaptured: false, contentTruncated: false, data,
|
|
2298
|
+
});
|
|
2248
2299
|
let activeTurn = null;
|
|
2249
2300
|
let closing = false;
|
|
2250
2301
|
let coreExitError = null;
|
|
@@ -2267,6 +2318,8 @@ export async function createFxEngine(options = {}) {
|
|
|
2267
2318
|
let appendTail = Promise.resolve();
|
|
2268
2319
|
// One per agent, so a store can tell a repeated write from another writer's.
|
|
2269
2320
|
const writerId = crypto.randomUUID();
|
|
2321
|
+
// Pipelined appends and barriers are modeled in `tests/tla/durable/Journal.tla`,
|
|
2322
|
+
// and two processes appending to one log in `tests/tla/durable/Fencing.tla`.
|
|
2270
2323
|
const journal = persistence && {
|
|
2271
2324
|
append(events, barrier) {
|
|
2272
2325
|
const data = encodeJournalRecord(events);
|
|
@@ -2279,6 +2332,7 @@ export async function createFxEngine(options = {}) {
|
|
|
2279
2332
|
journalHead = result.cursor;
|
|
2280
2333
|
if (Number.isSafeInteger(lastSeq)) appendedSeq = lastSeq;
|
|
2281
2334
|
bytesSinceCheckpoint += data.byteLength;
|
|
2335
|
+
journalHook("journal.commit", { cursor: result.cursor, firstSeq: Number.isSafeInteger(events[0]?.seq) ? events[0].seq : null, lastSeq: Number.isSafeInteger(lastSeq) ? lastSeq : null, eventCount: events.length });
|
|
2282
2336
|
});
|
|
2283
2337
|
// A failed append fails every later one: they would not continue it.
|
|
2284
2338
|
appendTail = appended;
|
|
@@ -2306,7 +2360,8 @@ export async function createFxEngine(options = {}) {
|
|
|
2306
2360
|
};
|
|
2307
2361
|
// Continues the open turn: the calls it left running run again or are
|
|
2308
2362
|
// answered as possibly run, and each answered one ends for the turn's
|
|
2309
|
-
// viewers as it does for the model.
|
|
2363
|
+
// viewers as it does for the model. Modeled in
|
|
2364
|
+
// `tests/tla/durable/Resumption.tla`.
|
|
2310
2365
|
const resumeOpenTurn = (promptOptions) => {
|
|
2311
2366
|
const { rerun, answered } = settleAmbiguous(promptOptions.onAmbiguous);
|
|
2312
2367
|
return normalizeTurn(startTurn(null, { ...promptOptions, [resumeTurn]: true, [rerunCallIds]: rerun }), answered);
|
|
@@ -2343,7 +2398,8 @@ export async function createFxEngine(options = {}) {
|
|
|
2343
2398
|
let handedOff = false;
|
|
2344
2399
|
const handedOffError = () => new Error("this agent handed its session off; open it again to continue");
|
|
2345
2400
|
// Input events (`accepted:<id>`, `withdrawn:<id>`) once stored, and the
|
|
2346
|
-
// steer and withdraw calls waiting for them
|
|
2401
|
+
// steer and withdraw calls waiting for them, which resolve only then
|
|
2402
|
+
// (`tests/tla/durable/Inbox.tla`).
|
|
2347
2403
|
const durableInputs = new Set();
|
|
2348
2404
|
const inputWaiters = new Map();
|
|
2349
2405
|
const waitForInput = (key) => {
|
|
@@ -2422,6 +2478,7 @@ export async function createFxEngine(options = {}) {
|
|
|
2422
2478
|
if (journalFailure) return;
|
|
2423
2479
|
journalFailure = journalAppendError(error);
|
|
2424
2480
|
emit("journal.error", { error: error instanceof Error ? error.name : "Error" });
|
|
2481
|
+
journalHook("journal.error", { error: error instanceof Error ? error.name : "Error", fenced: error?.code === "FX_FENCED" || error?.cause?.code === "FX_FENCED" });
|
|
2425
2482
|
rejectInputWaiters([...inputWaiters.keys()], journalFailure);
|
|
2426
2483
|
// Nothing the turn does from here can be saved, and after a fence the
|
|
2427
2484
|
// session has another owner: stop the turn and its effects now.
|
|
@@ -2560,7 +2617,10 @@ export async function createFxEngine(options = {}) {
|
|
|
2560
2617
|
};
|
|
2561
2618
|
const executeHostTool = async (name, input, requestedSessionId, toolCallId) => {
|
|
2562
2619
|
const execute = hostTools.executors.get(name);
|
|
2563
|
-
const
|
|
2620
|
+
const issuerSessionId = requestedSessionId ?? sessionId;
|
|
2621
|
+
const scope = hookScopes.get(`${issuerSessionId}:${toolCallId}`);
|
|
2622
|
+
// Hook metadata is not authority to execute a foreign session's tool.
|
|
2623
|
+
const turn = issuerSessionId === sessionId
|
|
2564
2624
|
? activeTurn
|
|
2565
2625
|
: null;
|
|
2566
2626
|
if (!isCurrentTurn(turn)) return { content: "", isError: true, cancelled: true };
|
|
@@ -2581,7 +2641,8 @@ export async function createFxEngine(options = {}) {
|
|
|
2581
2641
|
return execute(input, {
|
|
2582
2642
|
signal: controller.signal,
|
|
2583
2643
|
executionId: String(toolCallId ?? ""),
|
|
2584
|
-
sessionId,
|
|
2644
|
+
sessionId: issuerSessionId,
|
|
2645
|
+
...(options.hooks && scope ? { observation: toolObservation(scope, String(toolCallId), options.hooks, hook, controller.signal) } : {}),
|
|
2585
2646
|
...(options.toolContext === undefined ? {} : { context: options.toolContext }),
|
|
2586
2647
|
});
|
|
2587
2648
|
});
|
|
@@ -2695,12 +2756,13 @@ export async function createFxEngine(options = {}) {
|
|
|
2695
2756
|
// when the host could not. A journaled turn's first progress records its id.
|
|
2696
2757
|
const fx = {
|
|
2697
2758
|
...(followUp ? { inputId: followUp.id, inputAccepted: followUp.accepted } : {}),
|
|
2698
|
-
...(journal && turnId ? { turnId } : {}),
|
|
2759
|
+
...((journal || options.hooks) && turnId ? { turnId } : {}),
|
|
2699
2760
|
...stopAt,
|
|
2700
2761
|
};
|
|
2701
2762
|
return request("session/prompt", { sessionId, prompt, ...(Object.keys(fx).length ? { _meta: { fx } } : {}) });
|
|
2702
2763
|
};
|
|
2703
2764
|
runtime.exited.then((code) => {
|
|
2765
|
+
hookScopes.clear();
|
|
2704
2766
|
closing = true;
|
|
2705
2767
|
// An agent whose core exited writes nothing more.
|
|
2706
2768
|
if (journal) void closeJournal().catch(() => {});
|
|
@@ -2712,6 +2774,13 @@ export async function createFxEngine(options = {}) {
|
|
|
2712
2774
|
emit("runtime.exit", { code });
|
|
2713
2775
|
});
|
|
2714
2776
|
runtime.setLineHandler((message, size) => {
|
|
2777
|
+
if (message.method === "libfx/hook") {
|
|
2778
|
+
if (message.params?.rootSessionId === sessionId && message.params?.version === 1) {
|
|
2779
|
+
if (message.params.type === "turn.end" && handedOff) message.params.data.handoff = true;
|
|
2780
|
+
hook(message.params);
|
|
2781
|
+
}
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2715
2784
|
emit("acp.receive", { message });
|
|
2716
2785
|
if (message.method === "libfx/journal_append") {
|
|
2717
2786
|
if (journal && message.params?.sessionId === sessionId) {
|
|
@@ -2783,8 +2852,12 @@ export async function createFxEngine(options = {}) {
|
|
|
2783
2852
|
|
|
2784
2853
|
const loaded = persistence ? await loadPersistence(persistence) : null;
|
|
2785
2854
|
const requestedSessionId = options.sessionId ?? null;
|
|
2786
|
-
const sessionResult = await request("libfx/new",
|
|
2855
|
+
const sessionResult = await request("libfx/new", {
|
|
2856
|
+
...(requestedSessionId === null ? {} : { sessionId: requestedSessionId }),
|
|
2857
|
+
...(options.hooks ? { hooks: { captureContent: options.hooks.captureContent === true } } : {}),
|
|
2858
|
+
});
|
|
2787
2859
|
sessionId = sessionResult.sessionId;
|
|
2860
|
+
if (options.hooks && sessionResult?._meta?.fx?.hooksVersion !== 1) throw new Error("Semantic hooks require a matching libfx core with hooks version 1");
|
|
2788
2861
|
if (initialCheckpoint) {
|
|
2789
2862
|
const [checkpointAttachment] = attachBytes([initialCheckpoint]);
|
|
2790
2863
|
const restored = await request("libfx/restore", { sessionId, checkpointAttachment }).catch((error) => {
|
|
@@ -2828,6 +2901,7 @@ export async function createFxEngine(options = {}) {
|
|
|
2828
2901
|
followUps.push({ id: held.id, text: held.text, accepted: true, held: true });
|
|
2829
2902
|
}
|
|
2830
2903
|
emit("journal.open", { events: events.length, checkpoint: checkpoint !== null, turns: opened?.turns, resumable });
|
|
2904
|
+
journalHook("journal.restore", { cursor: loaded.head, lastSeq: loaded.lastSeq, resumable, openTurnId, lastTurnId });
|
|
2831
2905
|
}
|
|
2832
2906
|
} catch (error) {
|
|
2833
2907
|
closing = true;
|
|
@@ -2980,6 +3054,7 @@ export async function createFxEngine(options = {}) {
|
|
|
2980
3054
|
return run;
|
|
2981
3055
|
},
|
|
2982
3056
|
async close() {
|
|
3057
|
+
hookScopes.clear();
|
|
2983
3058
|
if (closing) {
|
|
2984
3059
|
await runtime.exited;
|
|
2985
3060
|
if (journal) await closeJournal();
|
package/fx-term.wasm
CHANGED
|
Binary file
|
package/libfx.darwin-arm64.node
CHANGED
|
Binary file
|
package/libfx.darwin-x64.node
CHANGED
|
Binary file
|
package/libfx.linux-arm64.node
CHANGED
|
Binary file
|
package/libfx.linux-x64.node
CHANGED
|
Binary file
|
package/mcp.js
CHANGED
|
@@ -33,6 +33,11 @@ export async function createMcpAdapter(client, options = {}) {
|
|
|
33
33
|
throw new TypeError("MCP client must provide listTools() and callTool()");
|
|
34
34
|
}
|
|
35
35
|
const prefix = options.prefix ?? "";
|
|
36
|
+
if (options.propagateCorrelation !== undefined && typeof options.propagateCorrelation !== "boolean") throw new TypeError("MCP propagateCorrelation must be a boolean");
|
|
37
|
+
if (options.serverName !== undefined && (typeof options.serverName !== "string" || options.serverName.length === 0 || options.serverName.length > 256)) throw new TypeError("MCP serverName must be a bounded name");
|
|
38
|
+
let reportedName;
|
|
39
|
+
try { reportedName = client.getServerVersion?.()?.name; } catch {}
|
|
40
|
+
const serverName = options.serverName ?? (typeof reportedName === "string" && reportedName.length > 0 && reportedName.length <= 256 ? reportedName : null);
|
|
36
41
|
if (typeof prefix !== "string" || !/^[A-Za-z0-9_-]*$/.test(prefix)) {
|
|
37
42
|
throw new TypeError("MCP prefix must contain only letters, digits, underscore, or hyphen");
|
|
38
43
|
}
|
|
@@ -70,8 +75,12 @@ export async function createMcpAdapter(client, options = {}) {
|
|
|
70
75
|
// The server's hints are not trusted: a call is never run twice on its
|
|
71
76
|
// own. A host that knows a tool is safe to repeat can set this to true.
|
|
72
77
|
idempotent: false,
|
|
73
|
-
async execute(input, { signal }) {
|
|
74
|
-
const
|
|
78
|
+
async execute(input, { signal, observation }) {
|
|
79
|
+
const correlationId = options.propagateCorrelation === true ? crypto.randomUUID() : undefined;
|
|
80
|
+
// Correlation is not progress reporting: progressToken requests remote
|
|
81
|
+
// notifications and some clients replace it with their JSON-RPC ID.
|
|
82
|
+
const send = () => client.callTool({ name: tool.name, arguments: input, ...(correlationId === undefined ? {} : { _meta: { "libfx/correlationId": correlationId } }) }, undefined, { signal });
|
|
83
|
+
const result = observation ? await observation.mcp({ server: serverName, tool: tool.name, correlationId }, send) : await send();
|
|
75
84
|
const text = resultText(result);
|
|
76
85
|
const images = (Array.isArray(result?.content) ? result.content : []).flatMap((item) => item?.type === "image" ? [item] : item?.type === "resource" && item.resource?.mimeType?.startsWith("image/") && typeof item.resource?.blob === "string" ? [{ type: "image", mimeType: item.resource.mimeType, data: item.resource.blob }] : []).map((item) => ({
|
|
77
86
|
type: "image", mimeType: item.mimeType, data: item.data,
|