@fungi.computer/mule 0.1.0 → 0.2.0

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.
@@ -1,8 +1,8 @@
1
- Mule's direct runtime dependencies are Effect 3.22.1, Cairn, and
2
- @earendil-works/pi-ai 0.84.4. Consult each dependency's distribution for its
1
+ Mule's direct runtime dependencies are Effect 4.0.2, Cairn, and
2
+ @earendil-works/pi-ai 1.3.0. Consult each dependency's distribution for its
3
3
  license terms.
4
4
 
5
- The Pi dependency is pinned from commit
5
+ The original Pi loop source was pinned from commit
6
6
  853a80d26c90a14c1886f0ebb8ffaae133ca2185 and distributed under the MIT
7
7
  License, Copyright (c) 2025 Mario Zechner. Its independently archived source
8
8
  boundary has SHA-256
@@ -13,3 +13,8 @@ dfb11f4c1c00e47003db12765371198e944c8b640c995a1dc1cf9130bc9e49ee.
13
13
  behavior from Botanical's retained @shiit/agent-core 0.66.2 at commit
14
14
  b6eabe5dfd3cac9514025d26abbe9a92b083f10b, distributed under the MIT License,
15
15
  Copyright (c) 2024 fungi.computer.
16
+
17
+ `src/types.ts` adapts the provider-neutral data declarations from
18
+ @earendil-works/pi-ai 1.3.0, distributed under the MIT License,
19
+ Copyright (c) 2025 Mario Zechner. Mule owns this public structural contract;
20
+ provider implementations remain in Pi.
@@ -1,6 +1,6 @@
1
- import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, TranscriptContext, ImageContent, JsonValue, Message, Model, RetryPolicy, SimpleStreamOptions, TextContent, ToolCall, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
1
+ import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, ImageContent, JsonValue, Message, Model, RetryPolicy, SimpleStreamOptions, TextContent, ToolCall, ToolResultMessage, Usage } from "./types.js";
2
2
  /** Pi's model stream function, supplied explicitly by the caller. */
3
- export type MuleStreamFunction = (model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
3
+ export type MuleStreamFunction = (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
4
4
  /** Causal roles added around Pi's model and tool loop. */
5
5
  export type MuleIdentityKind = "run" | "turn" | "message" | "tool_call" | "tool_result" | "terminal";
6
6
  /** One run-local causal identity. */
@@ -154,6 +154,12 @@ export type MuleRunSettings = {
154
154
  readonly streamOptions?: Omit<SimpleStreamOptions, "signal" | "maxRetries"> | undefined;
155
155
  /** Maximum silence between provider stream events, in milliseconds. */
156
156
  readonly modelStreamIdleTimeoutMs?: number | undefined;
157
+ /** Trusted per-attempt safety limits may only tighten Mule's defaults. */
158
+ readonly modelStreamLimits?: Partial<{
159
+ firstEventMs: number;
160
+ attemptMs: number;
161
+ outputBytes: number;
162
+ }> | undefined;
157
163
  readonly toolExecution?: "parallel" | "sequential" | undefined;
158
164
  /**
159
165
  * Bounded retry around each assistant call, using Pi's exponential delay.
@@ -200,4 +206,6 @@ export type MuleRunOutcome = {
200
206
  readonly terminal: MuleIdentity;
201
207
  readonly message: AssistantMessage;
202
208
  readonly messages: readonly Message[];
209
+ /** Mule-owned limit diagnostic, not untrusted provider prose. */
210
+ readonly modelLimitReason?: string;
203
211
  };
package/dist/effect.d.ts CHANGED
@@ -1,9 +1,12 @@
1
+ export { decodeProviderData } from "./provider-data.js";
2
+ export { streamPi as streamModel } from "./provider.js";
1
3
  export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
2
- import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "@earendil-works/pi-ai";
4
+ export { normalizeModel } from "./provider-model.js";
5
+ import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "./types.js";
3
6
  import * as Effect from "effect/Effect";
4
7
  import * as Stream from "effect/Stream";
5
8
  import type { MuleCompletedTurn, MuleError, MuleEvent, MuleRetryObservation, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
6
- export type { Api, AssistantMessage, AssistantMessageEvent, Context, ImageContent, JsonValue, Message, Model, RetryPolicy, SimpleStreamOptions, SystemMessage, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "@earendil-works/pi-ai";
9
+ export type * from "./types.js";
7
10
  export { Type } from "typebox";
8
11
  export type { MuleRetryObservation } from "./contract.js";
9
12
  /**
@@ -13,12 +16,6 @@ export type { MuleRetryObservation } from "./contract.js";
13
16
  * a host that wants Pi coding-agent's numbers passes this explicitly.
14
17
  */
15
18
  export declare const piCodingAgentRetryDefaults: RetryPolicy;
16
- /**
17
- * Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
18
- * `settings-manager.js`). A host that wants Pi's number for
19
- * `modelStreamIdleTimeoutMs` passes this explicitly.
20
- */
21
- export declare const piHttpIdleTimeoutMs = 300000;
22
19
  /** Apply Pi's maintained context-overflow semantics to an exact Pi message. */
23
20
  export declare function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
24
21
  export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
package/dist/effect.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
2
+ export { decodeProviderData } from "./provider-data.js";
3
+ export { streamPi as streamModel } from "./provider.js";
1
4
  import { nanoid } from "@fungi.computer/cairn";
2
5
  export { observeProviderStatus, } from "./provider-status.js";
3
6
  // Pi's root entry also evaluates its TypeBox validator compiler and model
@@ -5,7 +8,8 @@ export { observeProviderStatus, } from "./provider-status.js";
5
8
  // keeps that work out of every host's startup.
6
9
  import { isContextOverflow as piIsContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
7
10
  import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, retryDelayMs, } from "@earendil-works/pi-ai/utils/retry";
8
- import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
11
+ import { streamPi } from "./provider.js";
12
+ export { normalizeModel } from "./provider-model.js";
9
13
  import * as Effect from "effect/Effect";
10
14
  import * as Cause from "effect/Cause";
11
15
  import * as Result from "effect/Result";
@@ -26,18 +30,20 @@ export const piCodingAgentRetryDefaults = {
26
30
  baseDelayMs: 2000,
27
31
  maxAgentDelayMs: 60_000,
28
32
  };
29
- /**
30
- * Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
31
- * `settings-manager.js`). A host that wants Pi's number for
32
- * `modelStreamIdleTimeoutMs` passes this explicitly.
33
- */
34
- export const piHttpIdleTimeoutMs = 300_000;
33
+ /** Starting safety limits for one provider attempt; hosts may tighten them. Not public: the values are starting points, not a contract. */
34
+ const modelStreamLimits = {
35
+ firstEventMs: 60_000,
36
+ idleMs: 120_000,
37
+ attemptMs: 600_000,
38
+ outputBytes: 2 * 1024 * 1024,
39
+ };
35
40
  /**
36
41
  * Grace period a release waits for a provider iterator that ignores abort,
37
42
  * so a timed-out run's scope close never hangs. This is a Mule transport
38
43
  * detail with no Pi equivalent, so it stays a constant, not host config.
39
44
  */
40
45
  const providerIteratorReleaseGraceMs = 1_000;
46
+ const modelLimitCause = Symbol("Mule model limit");
41
47
  /** Apply Pi's maintained context-overflow semantics to an exact Pi message. */
42
48
  export function isContextOverflow(message, contextWindow) {
43
49
  return piIsContextOverflow(message, contextWindow);
@@ -231,17 +237,47 @@ function pullPiEvent(iterator, controller) {
231
237
  catch: (cause) => failure("model", "Pi model stream failed", cause),
232
238
  });
233
239
  }
234
- function withModelStreamDeadline(effect, timeoutMs) {
235
- if (timeoutMs === undefined)
236
- return effect;
240
+ function withModelStreamDeadline(effect, timeoutMs, reason) {
237
241
  return effect.pipe(Effect.timeoutOrElse({
238
242
  duration: timeoutMs,
239
- orElse: () => Effect.fail(failure("model", `Model stream was idle for ${String(timeoutMs)} ms; the provider connection stopped responding. Retry the run.`)),
243
+ orElse: () => Effect.fail(failure("model", reason(), modelLimitCause)),
240
244
  }));
241
245
  }
242
246
  function streamTurn(input, context, run, turn, emit, attempt) {
243
247
  const messageIdentity = identity("message", turn);
248
+ let partial;
249
+ const streamState = { started: false, terminal: undefined };
244
250
  return Effect.scoped(Effect.gen(function* () {
251
+ const limits = input.modelStreamLimits;
252
+ const firstEventMs = Math.min(limits?.firstEventMs ?? modelStreamLimits.firstEventMs, modelStreamLimits.firstEventMs);
253
+ const attemptMs = Math.min(limits?.attemptMs ?? modelStreamLimits.attemptMs, modelStreamLimits.attemptMs);
254
+ const outputLimit = Math.min(limits?.outputBytes ?? modelStreamLimits.outputBytes, modelStreamLimits.outputBytes);
255
+ if ([
256
+ firstEventMs,
257
+ attemptMs,
258
+ outputLimit,
259
+ input.modelStreamIdleTimeoutMs ?? 1,
260
+ ].some((value) => !Number.isSafeInteger(value) || value <= 0))
261
+ return yield* Effect.fail(failure("model", "Model stream limits must be positive safe integers"));
262
+ const startedAt = Date.now();
263
+ let lastEventAt = startedAt;
264
+ let maxGapMs = 0;
265
+ let outputBytes = 0;
266
+ const encoder = new TextEncoder();
267
+ let hasEvent = false;
268
+ const limitError = (kind) => failure("model", `Model ${kind} after ${String(Date.now() - startedAt)} ms (max provider gap ${String(Math.max(maxGapMs, Date.now() - lastEventAt))} ms, streamed ${String(outputBytes)} bytes)`, modelLimitCause);
269
+ const deadline = (effect) => {
270
+ const elapsed = Date.now() - startedAt;
271
+ const wholeRemaining = attemptMs - elapsed;
272
+ const gapLimit = Math.min(hasEvent ? modelStreamLimits.idleMs : firstEventMs, input.modelStreamIdleTimeoutMs ?? modelStreamLimits.idleMs);
273
+ const gapRemaining = gapLimit - (Date.now() - lastEventAt);
274
+ const kind = wholeRemaining <= gapRemaining
275
+ ? "attempt timed out"
276
+ : hasEvent
277
+ ? "stream timed out (silence)"
278
+ : "first event timed out";
279
+ return withModelStreamDeadline(effect, Math.max(0, Math.min(wholeRemaining, gapRemaining)), () => limitError(kind).message);
280
+ };
245
281
  const controller = new AbortController();
246
282
  yield* Effect.addFinalizer(() => Effect.sync(() => {
247
283
  controller.abort("Mule model scope closed");
@@ -269,18 +305,13 @@ function streamTurn(input, context, run, turn, emit, attempt) {
269
305
  const descriptors = (input.tools ?? []).map((tool) => tool.descriptor);
270
306
  if (descriptors.length > 0)
271
307
  piContext.tools = descriptors;
272
- const response = yield* withModelStreamDeadline(Effect.tryPromise({
308
+ const response = yield* deadline(Effect.tryPromise({
273
309
  try: async () => {
274
- const stream = input.stream ??
275
- (await import("@earendil-works/pi-ai/compat")).streamSimple;
310
+ const stream = input.stream ?? streamPi;
276
311
  return stream(input.model, normalizeContext(piContext), options);
277
312
  },
278
313
  catch: (cause) => failure("model", "Pi model stream could not start", cause),
279
- }), input.modelStreamIdleTimeoutMs);
280
- const streamState = {
281
- started: false,
282
- terminal: undefined,
283
- };
314
+ }));
284
315
  const iterator = yield* Effect.acquireRelease(Effect.sync(() => response[Symbol.asyncIterator]()), (active) => Effect.tryPromise({
285
316
  try: () => {
286
317
  controller.abort("Mule model stream closed");
@@ -300,6 +331,19 @@ function streamTurn(input, context, run, turn, emit, attempt) {
300
331
  catch: (cause) => failure("model", "Pi model stream failed", cause),
301
332
  }).pipe(Effect.ignore));
302
333
  const observeEvent = (event) => {
334
+ const now = Date.now();
335
+ maxGapMs = Math.max(maxGapMs, now - lastEventAt);
336
+ lastEventAt = now;
337
+ hasEvent = true;
338
+ if (event.type === "text_delta" ||
339
+ event.type === "thinking_delta" ||
340
+ event.type === "toolcall_delta") {
341
+ outputBytes += encoder.encode(event.delta).byteLength;
342
+ if (outputBytes > outputLimit)
343
+ return Effect.fail(limitError("output limit exceeded"));
344
+ }
345
+ if ("partial" in event)
346
+ partial = event.partial;
303
347
  switch (event.type) {
304
348
  case "start": {
305
349
  context.push(event.partial);
@@ -342,17 +386,17 @@ function streamTurn(input, context, run, turn, emit, attempt) {
342
386
  }
343
387
  }
344
388
  };
345
- const pull = () => withModelStreamDeadline(pullPiEvent(iterator, controller), input.modelStreamIdleTimeoutMs);
389
+ const pull = () => deadline(pullPiEvent(iterator, controller));
346
390
  let next = yield* pull();
347
391
  while (!next.done) {
348
392
  yield* observeEvent(next.value);
349
393
  next = yield* pull();
350
394
  }
351
395
  const completed = streamState.terminal ??
352
- (yield* withModelStreamDeadline(Effect.tryPromise({
396
+ (yield* deadline(Effect.tryPromise({
353
397
  try: () => response.result(),
354
398
  catch: (cause) => failure("model", "Pi model stream did not settle", cause),
355
- }), input.modelStreamIdleTimeoutMs));
399
+ })));
356
400
  if (streamState.started)
357
401
  context[context.length - 1] = completed;
358
402
  else {
@@ -373,7 +417,62 @@ function streamTurn(input, context, run, turn, emit, attempt) {
373
417
  value: completed,
374
418
  });
375
419
  return { identity: messageIdentity, message: completed };
376
- }));
420
+ }).pipe(Effect.catch((error) => {
421
+ if (error.cause !== modelLimitCause)
422
+ return Effect.fail(error);
423
+ const value = {
424
+ role: "assistant",
425
+ content: partial?.content ?? [],
426
+ api: input.model.api,
427
+ provider: input.model.provider,
428
+ model: input.model.id,
429
+ usage: partial?.usage ?? {
430
+ input: 0,
431
+ output: 0,
432
+ cacheRead: 0,
433
+ cacheWrite: 0,
434
+ totalTokens: 0,
435
+ cost: {
436
+ input: 0,
437
+ output: 0,
438
+ cacheRead: 0,
439
+ cacheWrite: 0,
440
+ total: 0,
441
+ },
442
+ },
443
+ stopReason: "error",
444
+ timestamp: Date.now(),
445
+ errorMessage: input.retry?.enabled && attempt > input.retry.maxRetries
446
+ ? `The model failed ${String(attempt)} times on this conversation: ${error.message}`
447
+ : error.message,
448
+ };
449
+ return Effect.gen(function* () {
450
+ if (!streamState.started) {
451
+ context.push(value);
452
+ yield* emit({
453
+ type: "message_start",
454
+ run,
455
+ turn,
456
+ message: messageIdentity,
457
+ value,
458
+ });
459
+ }
460
+ else
461
+ context[context.length - 1] = value;
462
+ yield* emit({
463
+ type: "message_end",
464
+ run,
465
+ turn,
466
+ message: messageIdentity,
467
+ value,
468
+ });
469
+ return {
470
+ identity: messageIdentity,
471
+ message: value,
472
+ limitReached: true,
473
+ };
474
+ });
475
+ })));
377
476
  }
378
477
  /**
379
478
  * Retry only the host's typed transient decision, using Pi's maintained delay.
@@ -427,13 +526,14 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
427
526
  policy === undefined ||
428
527
  !policy.enabled ||
429
528
  retries >= policy.maxRetries) {
430
- if (retries > 0)
529
+ if (retries > 0) {
431
530
  yield* finish(message.stopReason !== "error" &&
432
531
  message.stopReason !== "aborted", message.stopReason === "error" ? message.errorMessage : undefined);
532
+ }
433
533
  return latest;
434
534
  }
435
535
  const retryable = yield* Effect.try({
436
- try: () => input.retryable(message),
536
+ try: () => latest?.limitReached === true || input.retryable(message),
437
537
  catch: (cause) => failure("model", "Typed model retry decision failed", cause),
438
538
  });
439
539
  if (!retryable) {
@@ -526,11 +626,14 @@ function completeLoopTurn(input, context, newMessages, run, turn, pending, emit)
526
626
  message: streamed.message,
527
627
  toolResults: batch.messages,
528
628
  });
529
- return {
629
+ const completed = {
530
630
  message: streamed.message,
531
631
  toolResults: batch.messages,
532
632
  terminate: batch.terminate,
533
633
  };
634
+ if (streamed.limitReached && streamed.message.errorMessage !== undefined)
635
+ return { ...completed, modelLimitReason: streamed.message.errorMessage };
636
+ return completed;
534
637
  });
535
638
  }
536
639
  /** Resolve only missing results. A stored and current safe policy are both required. */
@@ -627,10 +730,19 @@ function selectNextTurn(input, turn, context, newMessages) {
627
730
  return { continue: followUps.length > 0, pending: followUps };
628
731
  });
629
732
  }
630
- function finishRun(run, terminal, message, messages, status, emit) {
733
+ function finishRun(run, terminal, message, messages, status, emit, modelLimitReason) {
631
734
  return Effect.gen(function* () {
632
735
  yield* emit({ type: "run_end", run, terminal, outcome: status });
633
- return { status, run, terminal, message, messages };
736
+ const outcome = {
737
+ status,
738
+ run,
739
+ terminal,
740
+ message,
741
+ messages,
742
+ };
743
+ if (modelLimitReason !== undefined)
744
+ return { ...outcome, modelLimitReason };
745
+ return outcome;
634
746
  });
635
747
  }
636
748
  /** Run Pi's model/tool loop inside Effect without changing Pi's values. */
@@ -699,7 +811,7 @@ function runLoop(input, emit, runIdentity, terminalIdentity) {
699
811
  finalMessage = completed.message;
700
812
  const status = statusFor(completed.message);
701
813
  if (status !== "completed") {
702
- return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit);
814
+ return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit, completed.modelLimitReason);
703
815
  }
704
816
  // Tool results always continue the run: this is where Pi checks its
705
817
  // context before the next assistant response (Pi 0.84.4+).
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { Message, Tool, ToolCall } from "@earendil-works/pi-ai";
1
+ export { decodeProviderData } from "./provider-data.js";
2
+ export { streamPi as streamModel } from "./provider.js";
3
+ export { normalizeModel } from "./provider-model.js";
4
+ import type { Message, Tool, ToolCall } from "./types.js";
2
5
  export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
3
6
  import type { MuleCompletedTurn, MuleEvent, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
4
7
  export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ export { decodeProviderData } from "./provider-data.js";
2
+ export { streamPi as streamModel } from "./provider.js";
3
+ export { normalizeModel } from "./provider-model.js";
1
4
  export { observeProviderStatus, } from "./provider-status.js";
2
5
  import * as Cause from "effect/Cause";
3
6
  import * as Effect from "effect/Effect";
@@ -0,0 +1,6 @@
1
+ import type { SimpleStreamOptions as PiOptions } from "@earendil-works/pi-ai";
2
+ import type { SimpleStreamOptions } from "./types.js";
3
+ /** Adapt Pi's parsed payload and event boundary before invoking owned hooks. */
4
+ export declare function piOptions(options: SimpleStreamOptions | undefined): PiOptions | undefined;
5
+ /** Restore the owned options shape when Pi calls a scripted provider factory. */
6
+ export declare function muleOptions(options: PiOptions | undefined): SimpleStreamOptions | undefined;
@@ -0,0 +1,35 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { decodeProviderData } from "./provider-data.js";
3
+ import { normalizeModel } from "./provider-model.js";
4
+ /** Adapt Pi's parsed payload and event boundary before invoking owned hooks. */
5
+ export function piOptions(options) {
6
+ if (options === undefined)
7
+ return undefined;
8
+ const { onPayload, onProviderStreamEvent, onResponse, ...rest } = options;
9
+ const adapted = { ...rest };
10
+ if (onPayload !== undefined)
11
+ adapted.onPayload = (payload, model) => onPayload(decodeProviderData(payload), normalizeModel(model));
12
+ if (onProviderStreamEvent !== undefined)
13
+ adapted.onProviderStreamEvent = (event, model) => onProviderStreamEvent(decodeProviderData(event), normalizeModel(model));
14
+ if (onResponse !== undefined)
15
+ adapted.onResponse = (response, model) => onResponse(response, normalizeModel(model));
16
+ return adapted;
17
+ }
18
+ /** Restore the owned options shape when Pi calls a scripted provider factory. */
19
+ export function muleOptions(options) {
20
+ if (options === undefined)
21
+ return undefined;
22
+ const { samplingParams, metadata, onPayload, ...rest } = options;
23
+ const adapted = { ...rest };
24
+ const decodeObject = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json));
25
+ if (samplingParams !== undefined)
26
+ adapted.samplingParams = decodeObject(samplingParams);
27
+ if (metadata !== undefined)
28
+ adapted.metadata = decodeObject(metadata);
29
+ if (onPayload !== undefined)
30
+ adapted.onPayload = async (payload, model) => {
31
+ const result = await onPayload(payload, model);
32
+ return result === undefined ? undefined : decodeProviderData(result);
33
+ };
34
+ return adapted;
35
+ }
@@ -0,0 +1,3 @@
1
+ import type { ProviderData } from "./types.js";
2
+ /** Decode Pi's parsed request/event data before it enters an owned hook. */
3
+ export declare function decodeProviderData(data: unknown): ProviderData;
@@ -0,0 +1,17 @@
1
+ import * as Schema from "effect/Schema";
2
+ const providerValue = Schema.suspend(() => Schema.Union([
3
+ Schema.Undefined,
4
+ Schema.Null,
5
+ Schema.Boolean,
6
+ Schema.Number,
7
+ Schema.String,
8
+ Schema.Uint8Array,
9
+ Schema.instanceOf(AbortSignal),
10
+ Schema.Array(providerValue),
11
+ providerData,
12
+ ]));
13
+ const providerData = Schema.Record(Schema.String, providerValue);
14
+ /** Decode Pi's parsed request/event data before it enters an owned hook. */
15
+ export function decodeProviderData(data) {
16
+ return Schema.decodeUnknownSync(providerData)(data);
17
+ }
@@ -0,0 +1,3 @@
1
+ import type { Api, Model } from "./types.js";
2
+ /** Validate a catalog model's opaque sampling fields before it enters Mule. */
3
+ export declare function normalizeModel<TApi extends Api>(model: Omit<Model<TApi>, "samplingParams" | "samplingParamsByThinkingLevel">): Model<TApi>;
@@ -0,0 +1,18 @@
1
+ import * as Schema from "effect/Schema";
2
+ const sampling = Schema.Record(Schema.String, Schema.Json);
3
+ const decodeSampling = Schema.decodeUnknownSync(Schema.Struct({
4
+ samplingParams: Schema.optionalKey(sampling),
5
+ samplingParamsByThinkingLevel: Schema.optionalKey(Schema.Struct({
6
+ off: Schema.optionalKey(sampling),
7
+ minimal: Schema.optionalKey(sampling),
8
+ low: Schema.optionalKey(sampling),
9
+ medium: Schema.optionalKey(sampling),
10
+ high: Schema.optionalKey(sampling),
11
+ xhigh: Schema.optionalKey(sampling),
12
+ max: Schema.optionalKey(sampling),
13
+ })),
14
+ }));
15
+ /** Validate a catalog model's opaque sampling fields before it enters Mule. */
16
+ export function normalizeModel(model) {
17
+ return { ...model, ...decodeSampling(model) };
18
+ }
@@ -1,4 +1,4 @@
1
- import type { SimpleStreamOptions } from "@earendil-works/pi-ai";
1
+ import type { SimpleStreamOptions } from "./types.js";
2
2
  /** Bounded provider evidence, never response bodies, headers or error prose. */
3
3
  export type ProviderRejection = Readonly<{
4
4
  code: "usage-limit-reached";
@@ -0,0 +1,3 @@
1
+ import type { MuleStreamFunction } from "./contract.js";
2
+ /** Pi's maintained stream behind Mule's structural provider port. */
3
+ export declare const streamPi: MuleStreamFunction;
@@ -0,0 +1,7 @@
1
+ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
2
+ import { piOptions } from "./pi-options.js";
3
+ /** Pi's maintained stream behind Mule's structural provider port. */
4
+ export const streamPi = async (model, context, options) => {
5
+ const { streamSimple } = await import("@earendil-works/pi-ai/compat");
6
+ return streamSimple(model, normalizeContext(context), piOptions(options));
7
+ };
package/dist/testing.d.ts CHANGED
@@ -1,17 +1,60 @@
1
- import { type FauxProviderHandle, type FauxResponseStep, type RegisterFauxProviderOptions } from "@earendil-works/pi-ai/providers/faux";
2
- import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
1
+ import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model } from "./types.js";
2
+ import type { MuleStreamFunction } from "./contract.js";
3
+ /** Credential-free model definition for the scripted provider. */
4
+ export type FauxModelDefinition = {
5
+ id: string;
6
+ name?: string;
7
+ reasoning?: boolean;
8
+ input?: ("text" | "image")[];
9
+ inputLimits?: Model<Api>["inputLimits"];
10
+ cost?: {
11
+ input: number;
12
+ output: number;
13
+ cacheRead: number;
14
+ cacheWrite: number;
15
+ };
16
+ contextWindow?: number;
17
+ maxTokens?: number;
18
+ };
19
+ /** Scripted provider configuration, independent of provider SDK declarations. */
20
+ export type RegisterFauxProviderOptions = {
21
+ api?: string;
22
+ provider?: string;
23
+ models?: FauxModelDefinition[];
24
+ deferred?: {
25
+ pendingFetches?: number;
26
+ pollAfterMs?: number;
27
+ };
28
+ tokensPerSecond?: number;
29
+ tokenSize?: {
30
+ min?: number;
31
+ max?: number;
32
+ };
33
+ };
34
+ /** Counters retained by the scripted provider. */
35
+ export type FauxProviderState = {
36
+ callCount: number;
37
+ deferredFetchCount: number;
38
+ cancelledDeferred: NonNullable<AssistantMessage["deferred"]>[];
39
+ };
40
+ /** Function that observes a model request and supplies a scripted reply. */
41
+ export type FauxResponseFactory = (context: Context, options: Parameters<MuleStreamFunction>[2], state: FauxProviderState, model: Model<Api>) => AssistantMessage | Promise<AssistantMessage>;
42
+ /** A scripted reply or a factory that observes the normalized request. */
43
+ export type FauxResponseStep = AssistantMessage | FauxResponseFactory;
3
44
  /** Deterministic model and stream with Pi's observable response state. */
4
45
  export type FauxModel = Readonly<{
5
46
  /** Model supplied to Mule's run input. */
6
47
  model: Model<Api>;
7
48
  /** Bound Pi faux stream; never calls a remote provider. */
8
- stream: FauxProviderHandle["provider"]["streamSimple"];
49
+ stream: (...args: Parameters<MuleStreamFunction>) => AssistantMessageEventStream;
9
50
  /** Scripted replies have no typed provider failure; opt into retry explicitly. */
10
51
  retryable: (message: AssistantMessage) => boolean;
11
52
  /** Replace the responses consumed by subsequent assistant calls. */
12
53
  setResponses: (replies: readonly FauxResponseStep[]) => void;
13
54
  /** Pi's call history and counters for deterministic assertions. */
14
- state: FauxProviderHandle["state"];
55
+ state: FauxProviderState;
56
+ /** Scripted replies not yet consumed, supplied by Pi's maintained queue. */
57
+ getPendingResponseCount: () => number;
15
58
  }>;
16
59
  /** Create a credential-free model for tests and runnable examples. */
17
60
  export declare function fauxModel(replies: readonly FauxResponseStep[], options?: RegisterFauxProviderOptions): FauxModel;
package/dist/testing.js CHANGED
@@ -1,15 +1,23 @@
1
- import { fauxProvider, } from "@earendil-works/pi-ai/providers/faux";
1
+ import { isFunction } from "effect/Predicate";
2
+ import { muleOptions, piOptions } from "./pi-options.js";
3
+ import { normalizeModel } from "./provider-model.js";
4
+ import { fauxProvider } from "@earendil-works/pi-ai/providers/faux";
5
+ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
2
6
  /** Create a credential-free model for tests and runnable examples. */
3
7
  export function fauxModel(replies, options = {}) {
4
8
  const faux = fauxProvider(options);
5
- faux.setResponses([...replies]);
9
+ const setResponses = (responses) => {
10
+ faux.setResponses(responses.map((step) => isFunction(step)
11
+ ? (context, options, state, model) => step(context, muleOptions(options), state, normalizeModel(model))
12
+ : step));
13
+ };
14
+ setResponses(replies);
6
15
  return {
7
- model: faux.getModel(),
8
- stream: faux.provider.streamSimple.bind(faux.provider),
16
+ model: normalizeModel(faux.getModel()),
17
+ stream: (model, context, streamOptions) => faux.provider.streamSimple(model, normalizeContext(context), piOptions(streamOptions)),
9
18
  retryable: () => false,
10
- setResponses: (responses) => {
11
- faux.setResponses([...responses]);
12
- },
19
+ setResponses,
13
20
  state: faux.state,
21
+ getPendingResponseCount: () => faux.getPendingResponseCount(),
14
22
  };
15
23
  }