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/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 turn = requestedSessionId === undefined || requestedSessionId === sessionId
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", requestedSessionId === null ? {} : { sessionId: requestedSessionId });
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
Binary file
Binary file
Binary file
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 result = await client.callTool({ name: tool.name, arguments: input }, undefined, { signal });
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,