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/node.js CHANGED
@@ -710,6 +710,8 @@ export const createFxAgent = createDurableAgentFactory({
710
710
  name: "createFxAgent",
711
711
  label: "fx agent",
712
712
  defaultDurability: environmentDurability,
713
+ // One durability per environment, so agents created without one share it.
714
+ defaultShared: true,
713
715
  harness: fxHarness({
714
716
  createEngine: createFxEngine,
715
717
  // An explicit key, then the durability's own credential, such as a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfx",
3
- "version": "0.0.14-dev.1534.g6afa7c33dac5",
3
+ "version": "0.0.14",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {
package/subscription.js CHANGED
@@ -7,6 +7,8 @@
7
7
  //
8
8
  // A provider spec supplies what differs: which requests it authorizes, how its
9
9
  // credentials are checked and refreshed, and which headers carry them.
10
+ //
11
+ // TLA+ model: `tests/tla/sdk/ProviderAuth.tla`.
10
12
 
11
13
  const refreshSkewMs = 5 * 60 * 1000;
12
14
  const maxRememberedCredentials = 256;
@@ -0,0 +1,185 @@
1
+ // Observations of host-owned effects. Helpers call the effect exactly once,
2
+ // preserve its value/error, and never await an instrumentation callback.
3
+ export function toolObservation(scope, callId, hooks, emit, signal) {
4
+ const report = (error) => {
5
+ try {
6
+ Promise.resolve(hooks.onError?.(error)).catch(() => {});
7
+ } catch {}
8
+ };
9
+ let next = 0;
10
+ const id = () => `host:${scope.executionId}:${callId}:${++next}`;
11
+ const publish = (type, data, content = {}) =>
12
+ emit({
13
+ ...scope,
14
+ type,
15
+ data,
16
+ timestamp: Date.now(),
17
+ contentCaptured: hooks.captureContent === true,
18
+ contentTruncated: content.truncated === true,
19
+ });
20
+ const capture = () => {
21
+ let remaining = 64 * 1024;
22
+ return {
23
+ truncated: false,
24
+ text(value) {
25
+ if (hooks.captureContent !== true || value == null) return undefined;
26
+ value = String(value);
27
+ let prefix = value.slice(0, remaining);
28
+ if (prefix.length && /[\uD800-\uDBFF]/.test(prefix.at(-1))) prefix = prefix.slice(0, -1);
29
+ const bytes = new TextEncoder().encode(prefix);
30
+ let end = Math.min(bytes.length, remaining);
31
+ while (end > 0 && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--;
32
+ remaining -= end;
33
+ this.truncated ||= prefix.length < value.length || end < bytes.length;
34
+ return new TextDecoder().decode(bytes.subarray(0, end));
35
+ },
36
+ };
37
+ };
38
+ function terminal(end) {
39
+ let ended = false;
40
+ const finish = (...args) => {
41
+ if (ended) return;
42
+ ended = true;
43
+ signal.removeEventListener("abort", abort);
44
+ try {
45
+ end(...args);
46
+ } catch (error) {
47
+ report(error);
48
+ }
49
+ };
50
+ const abort = () => finish("cancelled", undefined);
51
+ signal.addEventListener("abort", abort, { once: true });
52
+ if (signal.aborted) abort();
53
+ return finish;
54
+ }
55
+ return {
56
+ async wait(request, run) {
57
+ if (
58
+ !request ||
59
+ !["approval", "question", "mcp_form", "mcp_url"].includes(request.kind) ||
60
+ typeof run !== "function"
61
+ )
62
+ throw new TypeError("wait needs an input request and an effect");
63
+ if (signal.aborted) return run();
64
+ const requestId = id();
65
+ const content = capture();
66
+ content.truncated ||=
67
+ (request.questions?.length ?? 0) > 128 ||
68
+ (request.questions ?? [])
69
+ .slice(0, 128)
70
+ .some((question) => (question.options?.length ?? 0) > 128);
71
+ publish(
72
+ "wait.start",
73
+ {
74
+ requestId,
75
+ kind: request.kind,
76
+ callId,
77
+ externalId: request.externalId ?? null,
78
+ server: request.server ?? null,
79
+ schemaJson: content.text(request.schemaJson),
80
+ prompt: content.text(request.prompt),
81
+ questionCount: request.questions?.length ?? 0,
82
+ ...(hooks.captureContent === true
83
+ ? {
84
+ questions: (request.questions ?? []).slice(0, 128).map((question) => ({
85
+ question: content.text(question.question),
86
+ options: (question.options ?? []).slice(0, 128).map((option) => ({
87
+ label: content.text(option.label),
88
+ description: content.text(option.description),
89
+ })),
90
+ })),
91
+ }
92
+ : {}),
93
+ },
94
+ content,
95
+ );
96
+ const finish = terminal((outcome, result, error) => {
97
+ const content = capture();
98
+ content.truncated ||= (result?.answers?.length ?? 0) > 128;
99
+ publish(
100
+ "wait.end",
101
+ {
102
+ requestId,
103
+ outcome,
104
+ error: error?.name ?? null,
105
+ ...(hooks.captureContent === true
106
+ ? {
107
+ answers: (result?.answers ?? [])
108
+ .slice(0, 128)
109
+ .map((answer) => content.text(answer)),
110
+ }
111
+ : {}),
112
+ },
113
+ content,
114
+ );
115
+ });
116
+ try {
117
+ const result = await run();
118
+ let outcome = "accepted";
119
+ try {
120
+ outcome = result?.outcome ?? outcome;
121
+ } catch (error) {
122
+ report(error);
123
+ }
124
+ finish(outcome, result);
125
+ return result;
126
+ } catch (error) {
127
+ finish(
128
+ signal.aborted || error?.name === "AbortError"
129
+ ? "cancelled"
130
+ : error?.name === "TimeoutError"
131
+ ? "timed_out"
132
+ : "failed",
133
+ undefined,
134
+ error,
135
+ );
136
+ throw error;
137
+ }
138
+ },
139
+ async mcp(identity, run) {
140
+ if (signal.aborted) return run({ correlationId: identity.correlationId });
141
+ const operationId = id();
142
+ publish("mcp.start", {
143
+ operationId,
144
+ callId,
145
+ server: identity.server ?? null,
146
+ tool: identity.tool,
147
+ transport: null,
148
+ });
149
+ if (identity.correlationId !== undefined)
150
+ publish("mcp.request", {
151
+ operationId,
152
+ requestId: identity.correlationId,
153
+ requestIdKind: "correlation",
154
+ });
155
+ const finish = terminal((status, error) =>
156
+ publish("mcp.end", { operationId, status, error: error?.name ?? null }),
157
+ );
158
+ try {
159
+ const result = await run({ correlationId: identity.correlationId });
160
+ let status = "unknown";
161
+ try {
162
+ status = result?.isError ? "failure" : "success";
163
+ } catch (error) {
164
+ report(error);
165
+ }
166
+ finish(status);
167
+ return result;
168
+ } catch (error) {
169
+ finish(signal.aborted || error?.name === "AbortError" ? "cancelled" : "failure", error);
170
+ throw error;
171
+ }
172
+ },
173
+ childHooks() {
174
+ return {
175
+ ...hooks,
176
+ parent: {
177
+ rootSessionId: scope.rootSessionId,
178
+ sessionId: scope.sessionId,
179
+ turnId: scope.turnId,
180
+ callId,
181
+ },
182
+ };
183
+ },
184
+ };
185
+ }
@@ -53,6 +53,10 @@ export type {
53
53
  FxGatewayProvider,
54
54
  FxGrokProvider,
55
55
  FxHostTool,
56
+ FxHooks,
57
+ FxHookEvent,
58
+ FxHookMessage,
59
+ FxHookToolCall,
56
60
  FxIdleResult,
57
61
  FxImageBlock,
58
62
  FxJsonSchema,
@@ -92,6 +96,9 @@ export type {
92
96
  FxTextBlock,
93
97
  FxTool,
94
98
  FxToolContext,
99
+ FxToolObservation,
100
+ FxWaitRequest,
101
+ FxWaitResponse,
95
102
  FxToolImage,
96
103
  FxToolResult,
97
104
  FxTools,
@@ -41,7 +41,13 @@ export type WorldSource = World | (() => World | Promise<World>);
41
41
  export type WorldOptions = {
42
42
  /** The durability's name in errors. Default `"world"`. */
43
43
  name?: string;
44
- /** The queue keeps a message until a delivery acknowledges it, across crashes. When false, `prompt()` stores the prompt first. Default false. */
44
+ /**
45
+ * The store this World keeps sessions in. Queue messages name it, so one route hands each delivery to the agents on
46
+ * its store, and on one store an agent name belongs to one configuration. Give each customer of an app a store of its
47
+ * own. Default the durability's `name`.
48
+ */
49
+ store?: string;
50
+ /** The queue keeps a message until a delivery acknowledges it, across crashes. `prompt()` stores the prompt first either way. Default false. */
45
51
  queueDurable?: boolean;
46
52
  /** How often a waiting worker reads the session again. Default 1000. */
47
53
  pollMs?: number;
package/types/libfx.d.cts CHANGED
@@ -144,6 +144,32 @@ export interface FxToolContext {
144
144
  sessionId: string;
145
145
  /** The JSON passed as `context` to the `session()` call whose prompt or steer started the turn. */
146
146
  context?: JsonValue;
147
+ /** Present when hooks are installed. Observe host-owned input waits and child engines. */
148
+ observation?: FxToolObservation;
149
+ }
150
+
151
+ export interface FxWaitRequest {
152
+ kind: "approval" | "question" | "mcp_form" | "mcp_url";
153
+ externalId?: string | number;
154
+ server?: string;
155
+ prompt?: string;
156
+ schemaJson?: string;
157
+ questions?: { question: string; options?: { label: string; description?: string }[] }[];
158
+ }
159
+
160
+ export interface FxWaitResponse<T = unknown> {
161
+ outcome: "accepted" | "denied" | "cancelled" | "failed" | "timed_out";
162
+ answers?: string[];
163
+ value?: T;
164
+ }
165
+
166
+ export interface FxToolObservation {
167
+ /** Calls the host effect once and preserves its response/error. Never makes a permission decision. */
168
+ wait<T>(request: FxWaitRequest, run: () => FxWaitResponse<T> | Promise<FxWaitResponse<T>>): Promise<FxWaitResponse<T>>;
169
+ /** Used by the MCP adapter to bind a remote call to this tool invocation. */
170
+ mcp<T>(identity: { server: string | null; tool: string; correlationId?: string | undefined }, run: (request: { correlationId: string | undefined }) => T | Promise<T>): Promise<T>;
171
+ /** Install these hooks on a host-created child engine to retain the parent tool relationship. */
172
+ childHooks(): FxHooks;
147
173
  }
148
174
 
149
175
  /** An image in a typed tool result: base64 `data`, or a `sourceRef` with no `data`. */
@@ -294,6 +320,8 @@ export type FxSessionEvent = FxEventBase & (
294
320
  | { type: "session.fenced"; sessionId: string; epoch: number }
295
321
  /** A message for a session of the agent `agent` reached this one, which sent it on. */
296
322
  | { type: "session.forwarded"; sessionId: string; agent: string }
323
+ /** A queue delivery named an input the session's log does not hold, or none at all, so it ran nothing. */
324
+ | { type: "session.refused"; sessionId?: string; messageId?: string; reason: "unknown message" | "malformed message" }
297
325
  /** `cutoffs`: the times in a row the deadline cut off the same step, this one included. */
298
326
  | { type: "session.deadline"; sessionId: string; epoch: number; cutoffs: number }
299
327
  | { type: "ui.error"; sessionId: string; error: string }
@@ -305,8 +333,107 @@ export type FxAgentEvent = FxEngineEvent | FxSessionEvent;
305
333
  // ---------------------------------------------------------------------------
306
334
  // Options shared by agents and engines
307
335
 
336
+ /** Semantic observation, independent of diagnostics, UI events, permissions, and tracing libraries. */
337
+ export interface FxHooks {
338
+ /** Parent of a host-created child engine. Normally supplied by `context.observation.childHooks()`. */
339
+ parent?: { rootSessionId: string; sessionId: string; turnId: string; callId: string };
340
+ /** Include bounded messages, instructions, tool arguments/results, and schemas. Default false. */
341
+ captureContent?: boolean;
342
+ /** Called in event order, but returned promises are not awaited. The host owns buffering and flushing. */
343
+ onEvent(event: FxHookEvent): void | Promise<void>;
344
+ /** Receives callback throws and rejected promises. Errors here are contained too. */
345
+ onError?(error: unknown): void | Promise<void>;
346
+ }
347
+
348
+ /** An authoritative model call identity, with no provider replay or credentials. */
349
+ export interface FxHookToolCall {
350
+ callId: string;
351
+ name: string;
352
+ provenance: "fx_local" | "provider_executed";
353
+ /** Present only with content capture. May be a truncated JSON string. */
354
+ argumentsJson?: string | null;
355
+ }
356
+
357
+ export interface FxHookMessage {
358
+ role: "system" | "user" | "assistant" | "tool";
359
+ content?: string | null;
360
+ toolCallId: string | null;
361
+ toolName: string | null;
362
+ toolResultStatus: "success" | "failure" | null;
363
+ imageCount: number;
364
+ toolCalls: FxHookToolCall[];
365
+ }
366
+
367
+ interface FxHookEnvelope {
368
+ version: 1;
369
+ sessionId: string;
370
+ rootSessionId: string;
371
+ parentSessionId: string | null;
372
+ sourceSessionId: string | null;
373
+ workId: string | null;
374
+ parentTurnId?: string;
375
+ parentCallId?: string;
376
+ /** Stable host turn ID; null for session-scoped journal receipts. */
377
+ turnId: string | null;
378
+ /** Fresh for each execution/resume, not a trace or durable operation ID. */
379
+ executionId: string | null;
380
+ /** One-based within this execution, or 0 outside a step. */
381
+ stepIndex: number;
382
+ /** Milliseconds since the epoch, sampled at the semantic boundary. */
383
+ timestamp: number;
384
+ contentCaptured: boolean;
385
+ /** A content budget or collection limit omitted information. */
386
+ contentTruncated: boolean;
387
+ }
388
+
389
+ /** Versioned execution facts. The host chooses topology, conventions, redaction, and export. */
390
+ export type FxHookEvent = FxHookEnvelope & (
391
+ | { type: "turn.start"; data: { resumed: boolean; input?: string | null } }
392
+ | { type: "turn.end"; data: { outcome: "completed" | "interrupted" | "failed" | "paused" | "yielded" | "cancelled" | "fenced"; error: string | null; handoff?: boolean } }
393
+ | { type: "step.start" | "step.end"; data: Record<string, never> }
394
+ | { type: "model.start"; data: {
395
+ /** Scoped to executionId. Retries have distinct IDs. */
396
+ modelCallId: number;
397
+ parentCallId: string | null;
398
+ purpose: "agent" | "compaction" | "vision" | "title" | "permission_review" | "auxiliary";
399
+ model: string; toolChoice: string; maxOutputTokens: number | null; messageCount: number;
400
+ instructions?: FxHookMessage[]; messages?: FxHookMessage[];
401
+ toolsJson?: string | null;
402
+ tools: { name: string; description?: string | null; inputSchemaJson?: string | null }[];
403
+ } }
404
+ | { type: "model.admitted" | "model.output_start"; data: { modelCallId: number } }
405
+ | { type: "model.end"; data: {
406
+ modelCallId: number; error: string | null; cancelled: boolean; output?: string | null;
407
+ finishReason?: string | null; generationId?: string | null; provider?: string | null;
408
+ usage?: { inputTokens: number | null; outputTokens: number | null; cacheReadTokens: number | null; cacheWriteTokens: number | null; reasoningTokens: number | null };
409
+ toolCalls?: FxHookToolCall[];
410
+ } }
411
+ | { type: "tool.start"; data: { call: FxHookToolCall; rerun: boolean } }
412
+ | { type: "permission.start"; data: { call: FxHookToolCall } }
413
+ | { type: "tool.end"; data: {
414
+ call: FxHookToolCall; output?: string | null; status: "success" | "failure" | "cancelled"; error: string | null;
415
+ usage?: { inputTokens: number; outputTokens: number; webSearchRequests: number };
416
+ } }
417
+ /** Acceptance into conversation memory, not proof of durable storage. */
418
+ | { type: "tool.result"; data: { call: FxHookToolCall; output?: string | null; status: "success" | "failure" } }
419
+ /** Permission evaluation can complete without a human prompt. */
420
+ | { type: "permission.end"; data: { call: FxHookToolCall; decision: "once" | "always" | "deny" | "policy_denied" | "permission_required" | null; error: string | null } }
421
+ | { type: "wait.start"; data: { requestId: number | string; kind: FxWaitRequest["kind"]; callId: string | null; externalId: string | number | null; server: string | null; prompt?: string | null; schemaJson?: string | null; questionCount: number; questions?: { question: string; submission?: "none" | "input" | "choice"; options: { label: string; description?: string | null }[] }[] } }
422
+ | { type: "wait.end"; data: { requestId: number | string; outcome: FxWaitResponse["outcome"]; error: string | null; answers?: string[] } }
423
+ | { type: "mcp.start"; data: { operationId: number | string; callId: string | null; server: string | null; tool: string; transport: string | null } }
424
+ | { type: "mcp.request"; data: { operationId: number | string; requestId: number | string; requestIdKind: "jsonrpc" | "engine" | "correlation" } }
425
+ | { type: "mcp.end"; data: { operationId: number | string; status: "success" | "failure" | "cancelled" | "input_required" | "unavailable" | "unknown"; error: string | null } }
426
+ | { type: "delegation"; data: { callId: string; childSessionId: string; workId: string; operationId: string | null; phase: "dispatched" | "completed" | "yielded" | "replayed" | "failed" | "cancelled" | "feedback_queued" | "feedback_applied" | "feedback_not_applied" } }
427
+ /** Emitted only after append resolves and its cursor validates. */
428
+ | { type: "journal.commit"; data: { cursor: string; firstSeq: number | null; lastSeq: number | null; eventCount: number } }
429
+ | { type: "journal.restore"; data: { cursor: string | null; lastSeq: number; resumable: boolean; openTurnId: string | null; lastTurnId: string | null } }
430
+ | { type: "journal.error"; data: { error: string; fenced: boolean } }
431
+ );
432
+
308
433
  /** Options `createFxAgent()` and `createFxEngine()` share. */
309
434
  export interface FxSharedOptions<Event> {
435
+ /** Host-owned instrumentation of native and Wasm execution. Disabled by default. */
436
+ hooks?: FxHooks;
310
437
  /** The complete host-owned system context, up to 64 KiB of UTF-8. An array is joined with blank lines. */
311
438
  instructions?: string | readonly string[];
312
439
  tools?: FxTools;
@@ -627,7 +754,7 @@ export type FxAgentOptions = Omit<FxSharedOptions<FxAgentEvent>, "tools"> & FxMo
627
754
 
628
755
  /** Options for `agent.session()`. Each applies to the turns this session object starts. */
629
756
  export interface FxSessionOptions {
630
- /** JSON every tool call of those turns receives as `context`. */
757
+ /** JSON every tool call of those turns receives as `context`, copied when `session()` is called. */
631
758
  context?: JsonValue;
632
759
  /** The model for those turns instead of the agent's whole model choice: a model ID, or a model object. */
633
760
  model?: string | FxModelOptions;
@@ -647,8 +774,7 @@ export interface FxPromptOptions {
647
774
  export interface FxSteerOptions {
648
775
  /**
649
776
  * The steer's id, so a retried request adds nothing. Follows the session id rule. Reusing a prompt's id rejects
650
- * with `FX_MESSAGE_ID_IN_USE`; on a queue that outlives the process, the worker drops the steer and emits a
651
- * `session.error` with that code instead.
777
+ * with `FX_MESSAGE_ID_IN_USE`.
652
778
  */
653
779
  messageId?: string;
654
780
  }
package/types/mcp.d.ts CHANGED
@@ -10,7 +10,8 @@ export interface McpClient {
10
10
  /** Resolves to the tools array or `{ tools, nextCursor? }`. */
11
11
  listTools(params?: { cursor: string }): unknown;
12
12
  /** Called with the tool's original name, the model's arguments, and the turn's `signal` in `options`. */
13
- callTool(params: { name: string; arguments?: unknown }, resultSchema?: unknown, options?: { signal?: AbortSignal }): unknown;
13
+ callTool(params: { name: string; arguments?: unknown; _meta?: { "libfx/correlationId": string } }, resultSchema?: unknown, options?: { signal?: AbortSignal }): unknown;
14
+ getServerVersion?(): { name?: string } | undefined;
14
15
  /** Required when `resources` is given. */
15
16
  readResource?(params: { uri: string }): unknown;
16
17
  /** Required when `prompts` is given. */
@@ -20,6 +21,10 @@ export interface McpClient {
20
21
 
21
22
  /** Options for `createMcpAdapter()`. */
22
23
  export interface McpAdapterOptions {
24
+ /** Local server identity for hook events. Defaults to the client's reported server name, or null. */
25
+ serverName?: string;
26
+ /** Send an opaque UUID as `_meta["libfx/correlationId"]`. Default false; doesn't request MCP progress notifications. */
27
+ propagateCorrelation?: boolean;
23
28
  /** Prepended to each tool name: letters, digits, `_`, or `-`. */
24
29
  prefix?: string;
25
30
  /** Resource URIs whose text becomes instructions. */
package/types/node.d.cts CHANGED
@@ -62,6 +62,10 @@ export type {
62
62
  FxGatewayProvider,
63
63
  FxGrokProvider,
64
64
  FxHostTool,
65
+ FxHooks,
66
+ FxHookEvent,
67
+ FxHookMessage,
68
+ FxHookToolCall,
65
69
  FxIdleResult,
66
70
  FxImageBlock,
67
71
  FxJsonSchema,
@@ -103,6 +107,9 @@ export type {
103
107
  FxTextBlock,
104
108
  FxTool,
105
109
  FxToolContext,
110
+ FxToolObservation,
111
+ FxWaitRequest,
112
+ FxWaitResponse,
106
113
  FxToolImage,
107
114
  FxToolResult,
108
115
  FxTools,
package/types/wasm.d.ts CHANGED
@@ -35,6 +35,10 @@ export type {
35
35
  FxGatewayProvider,
36
36
  FxGrokProvider,
37
37
  FxHostTool,
38
+ FxHooks,
39
+ FxHookEvent,
40
+ FxHookMessage,
41
+ FxHookToolCall,
38
42
  FxImageBlock,
39
43
  FxJsonSchema,
40
44
  FxKeyedTool,
@@ -62,6 +66,9 @@ export type {
62
66
  FxTextBlock,
63
67
  FxTool,
64
68
  FxToolContext,
69
+ FxToolObservation,
70
+ FxWaitRequest,
71
+ FxWaitResponse,
65
72
  FxToolImage,
66
73
  FxToolResult,
67
74
  FxTools,