@fungi.computer/mule 0.0.1 → 0.1.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.
package/README.md CHANGED
@@ -40,6 +40,7 @@ const faux = fauxModel([fauxAssistantMessage("pong")]);
40
40
  const outcome = await mule.run(
41
41
  {
42
42
  model: faux.model,
43
+ retryable: faux.retryable,
43
44
  stream: faux.stream,
44
45
  systemPrompt: "Reply once.",
45
46
  messages: [{ role: "user", content: "ping", timestamp: 1 }],
@@ -20,7 +20,8 @@ export type MuleError = {
20
20
  /** Result produced by a tool before Mule forms Pi's correlated result message. */
21
21
  export type MuleToolResult = {
22
22
  readonly content: (TextContent | ImageContent)[];
23
- readonly details?: JsonValue | undefined;
23
+ /** Application payload for listeners, never part of a model message. */
24
+ readonly data?: JsonValue | undefined;
24
25
  readonly usage?: Usage | undefined;
25
26
  readonly isError: boolean;
26
27
  readonly terminate?: boolean | undefined;
@@ -79,6 +80,8 @@ export type MuleEvent = {
79
80
  readonly turn: MuleIdentity;
80
81
  readonly message: MuleIdentity;
81
82
  readonly value: Message;
83
+ /** Application payload independent of the completed model message. */
84
+ readonly data?: JsonValue | undefined;
82
85
  } | {
83
86
  readonly type: "tool_execution_start";
84
87
  readonly replay: "safe" | "unsafe";
@@ -147,20 +150,28 @@ export type MuleRunSettings = {
147
150
  /** Continue a durable, completed assistant turn before requesting another response. */
148
151
  readonly resume?: MuleResume | undefined;
149
152
  /** Pi stream options. Cancellation is the run's interruption, never a signal here. */
150
- readonly streamOptions?: Omit<SimpleStreamOptions, "signal"> | undefined;
153
+ /** Provider-native retries are disabled; the typed host policy owns retry. */
154
+ readonly streamOptions?: Omit<SimpleStreamOptions, "signal" | "maxRetries"> | undefined;
151
155
  /** Maximum silence between provider stream events, in milliseconds. */
152
156
  readonly modelStreamIdleTimeoutMs?: number | undefined;
153
157
  readonly toolExecution?: "parallel" | "sequential" | undefined;
154
158
  /**
155
- * Pi's bounded retry policy around each assistant call (`retryAssistantCall`).
159
+ * Bounded retry around each assistant call, using Pi's exponential delay.
156
160
  * Undefined means no retry, matching pi-ai's own default. Hosts that want
157
161
  * Pi coding-agent's numbers pass `piCodingAgentRetryDefaults`.
162
+ * Enabled policy counts and delays must be non-negative safe integers.
163
+ * Each assistant turn captures its policy before requesting the model.
158
164
  */
159
165
  readonly retry?: RetryPolicy | undefined;
166
+ /**
167
+ * Host-owned typed decision for this exact assistant message. Only true
168
+ * permits retry; unknown messages return false, never a decision from prose.
169
+ */
170
+ readonly retryable: (message: AssistantMessage) => boolean;
160
171
  /** Provider's bounded Retry-After evidence for the last assistant error. */
161
172
  readonly retryAfterMs?: ((message: AssistantMessage) => number | undefined) | undefined;
162
173
  };
163
- /** One observed Pi-retry event around a single assistant call. */
174
+ /** One observed typed-retry event around a single assistant call. */
164
175
  export type MuleRetryObservation = {
165
176
  readonly type: "retry_scheduled";
166
177
  readonly run: MuleIdentity;
package/dist/effect.d.ts CHANGED
@@ -1,9 +1,10 @@
1
+ export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
1
2
  import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "@earendil-works/pi-ai";
2
3
  import * as Effect from "effect/Effect";
3
4
  import * as Stream from "effect/Stream";
4
5
  import type { MuleCompletedTurn, MuleError, MuleEvent, MuleRetryObservation, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
5
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";
6
- export { Type } from "@earendil-works/pi-ai";
7
+ export { Type } from "typebox";
7
8
  export type { MuleRetryObservation } from "./contract.js";
8
9
  /**
9
10
  * Pi coding-agent's retry defaults (`settings-manager.js` `getRetrySettings()`,
package/dist/effect.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import { nanoid } from "@fungi.computer/cairn";
2
- import { isContextOverflow as piIsContextOverflow, normalizeContext, retryAssistantCall, } from "@earendil-works/pi-ai";
2
+ export { observeProviderStatus, } from "./provider-status.js";
3
+ // Pi's root entry also evaluates its TypeBox validator compiler and model
4
+ // stores; Mule needs only these leaf helpers, so it imports them directly and
5
+ // keeps that work out of every host's startup.
6
+ import { isContextOverflow as piIsContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
7
+ 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";
3
9
  import * as Effect from "effect/Effect";
4
- import * as Schema from "effect/Schema";
5
10
  import * as Cause from "effect/Cause";
6
11
  import * as Result from "effect/Result";
7
12
  import * as Exit from "effect/Exit";
8
- import * as Fiber from "effect/Fiber";
9
13
  import * as Option from "effect/Option";
10
14
  import * as Semaphore from "effect/Semaphore";
11
15
  import * as Stream from "effect/Stream";
12
- export { Type } from "@earendil-works/pi-ai";
16
+ export { Type } from "typebox";
13
17
  /**
14
18
  * Pi coding-agent's retry defaults (`settings-manager.js` `getRetrySettings()`,
15
19
  * `@earendil-works/pi-ai` 0.99.0, the version pinned in the lockfile). Pi-ai's
@@ -39,13 +43,6 @@ export function isContextOverflow(message, contextWindow) {
39
43
  return piIsContextOverflow(message, contextWindow);
40
44
  }
41
45
  const failure = (phase, message, cause) => ({ _tag: "MuleError", phase, message, cause });
42
- const muleErrorSchema = Schema.Struct({
43
- _tag: Schema.Literal("MuleError"),
44
- phase: Schema.Literals(["model", "tool", "control", "listener"]),
45
- message: Schema.String,
46
- cause: Schema.optionalKey(Schema.Unknown),
47
- });
48
- const isMuleError = (value) => Schema.is(muleErrorSchema)(value);
49
46
  const identity = (kind, parent) => ({ kind, value: `mule_${kind}_${nanoid()}`, parent });
50
47
  const noListener = () => Effect.void;
51
48
  function resultMessage(call, result) {
@@ -57,8 +54,6 @@ function resultMessage(call, result) {
57
54
  isError: result.isError,
58
55
  timestamp: Date.now(),
59
56
  };
60
- if (result.details !== undefined)
61
- message.details = result.details;
62
57
  if (result.usage !== undefined)
63
58
  message.usage = result.usage;
64
59
  return message;
@@ -169,6 +164,7 @@ function emitToolResult(executed, run, turn, emit) {
169
164
  turn,
170
165
  message: messageIdentity,
171
166
  value: message,
167
+ data: executed.result.data,
172
168
  });
173
169
  return message;
174
170
  });
@@ -256,6 +252,7 @@ function streamTurn(input, context, run, turn, emit, attempt) {
256
252
  input.streamOptions?.apiKey;
257
253
  const options = {
258
254
  ...input.streamOptions,
255
+ maxRetries: 0,
259
256
  signal: controller.signal,
260
257
  };
261
258
  if (resolvedApiKey !== undefined)
@@ -379,9 +376,8 @@ function streamTurn(input, context, run, turn, emit, attempt) {
379
376
  }));
380
377
  }
381
378
  /**
382
- * Run one assistant call with Pi's bounded retry (`retryAssistantCall`)
383
- * around it. `input.retry` undefined or disabled means `retryAssistantCall`
384
- * calls `streamTurn` exactly once and returns its result unchanged.
379
+ * Retry only the host's typed transient decision, using Pi's maintained delay.
380
+ * Effect owns interruption of both the active stream and the backoff sleep.
385
381
  *
386
382
  * Each attempt streams and is recorded exactly like any other turn (so a
387
383
  * failed attempt stays in the retained event log for inspection), but a
@@ -390,123 +386,91 @@ function streamTurn(input, context, run, turn, emit, attempt) {
390
386
  */
391
387
  function streamTurnWithRetry(input, context, run, turn, emit) {
392
388
  return Effect.gen(function* () {
393
- const services = yield* Effect.context();
394
- const runFork = Effect.runForkWith(services);
395
- const runPromise = Effect.runPromiseWith(services);
389
+ // One turn owns one finite budget, even if its caller updates future policy.
390
+ const policy = input.retry === undefined ? undefined : { ...input.retry };
391
+ if (policy?.enabled &&
392
+ [
393
+ policy.maxRetries,
394
+ policy.baseDelayMs,
395
+ policy.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS,
396
+ ].some((value) => !Number.isSafeInteger(value) || value < 0))
397
+ return yield* Effect.fail(failure("model", "Retry policy requires non-negative safe-integer counts and delays"));
396
398
  let latest;
397
- let attempt = 0;
398
- let retrySignal;
399
- const reportRetry = (event) => input.onRetry === undefined
400
- ? undefined
401
- : runPromise(input.onRetry(event));
402
- const callbacks = {
403
- onRetryScheduled: async (attempt, maxAttempts, delayMs, errorMessage) => {
404
- // Pi owns the only retry loop and its exponential schedule. A clean
405
- // gateway 429 can request a longer delay; extend that same scheduled
406
- // sleep (capped), never dispatch a second transport-level retry.
407
- const after = latest === undefined
408
- ? undefined
409
- : input.retryAfterMs?.(latest.message);
410
- const extra = after !== undefined &&
399
+ let calls = 0;
400
+ let retries = 0;
401
+ let finished = false;
402
+ let lastError;
403
+ const reportRetry = (event) => input.onRetry === undefined ? Effect.void : input.onRetry(event);
404
+ const finish = (success, finalError) => Effect.gen(function* () {
405
+ finished = true;
406
+ yield* reportRetry(finalError === undefined
407
+ ? { type: "retry_finished", run, turn, attempt: retries, success }
408
+ : {
409
+ type: "retry_finished",
410
+ run,
411
+ turn,
412
+ attempt: retries,
413
+ success,
414
+ finalError,
415
+ });
416
+ });
417
+ return yield* Effect.gen(function* () {
418
+ for (;;) {
419
+ if (latest !== undefined) {
420
+ const index = context.indexOf(latest.message);
421
+ if (index !== -1)
422
+ context.splice(index, 1);
423
+ }
424
+ latest = yield* streamTurn(input, context, run, turn, emit, ++calls);
425
+ const message = latest.message;
426
+ if (message.stopReason !== "error" ||
427
+ policy === undefined ||
428
+ !policy.enabled ||
429
+ retries >= policy.maxRetries) {
430
+ if (retries > 0)
431
+ yield* finish(message.stopReason !== "error" &&
432
+ message.stopReason !== "aborted", message.stopReason === "error" ? message.errorMessage : undefined);
433
+ return latest;
434
+ }
435
+ const retryable = yield* Effect.try({
436
+ try: () => input.retryable(message),
437
+ catch: (cause) => failure("model", "Typed model retry decision failed", cause),
438
+ });
439
+ if (!retryable) {
440
+ if (retries > 0)
441
+ yield* finish(false, message.errorMessage);
442
+ return latest;
443
+ }
444
+ retries++;
445
+ lastError = message.errorMessage ?? "Unknown error";
446
+ const after = yield* Effect.try({
447
+ try: () => input.retryAfterMs?.(message),
448
+ catch: (cause) => failure("model", "Model retry delay observation failed", cause),
449
+ });
450
+ const delayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
411
451
  Number.isSafeInteger(after) &&
412
452
  after >= 0 &&
413
453
  after <= 60_000
414
- ? Math.max(0, after - delayMs)
415
- : 0;
416
- await reportRetry({
454
+ ? after
455
+ : 0);
456
+ yield* reportRetry({
417
457
  type: "retry_scheduled",
418
458
  run,
419
459
  turn,
420
- attempt,
421
- maxAttempts,
422
- delayMs: delayMs + extra,
423
- errorMessage,
460
+ attempt: retries,
461
+ maxAttempts: policy.maxRetries,
462
+ delayMs,
463
+ errorMessage: lastError,
424
464
  });
425
- if (extra > 0)
426
- await new Promise((resolve) => {
427
- const timer = setTimeout(() => {
428
- retrySignal?.removeEventListener("abort", abort);
429
- resolve();
430
- }, extra);
431
- const abort = () => {
432
- clearTimeout(timer);
433
- resolve();
434
- };
435
- retrySignal?.addEventListener("abort", abort, { once: true });
436
- if (retrySignal?.aborted)
437
- abort();
438
- });
439
- },
440
- onRetryAttemptStart: () => reportRetry({ type: "retry_attempt_started", run, turn, attempt }),
441
- onRetryFinished: (success, attempt, finalError) => reportRetry(finalError === undefined
442
- ? { type: "retry_finished", run, turn, attempt, success }
443
- : {
444
- type: "retry_finished",
465
+ yield* Effect.sleep(delayMs);
466
+ yield* reportRetry({
467
+ type: "retry_attempt_started",
445
468
  run,
446
469
  turn,
447
- attempt,
448
- success,
449
- finalError,
450
- }),
451
- };
452
- const result = yield* Effect.tryPromise({
453
- try: (signal) => {
454
- retrySignal = signal;
455
- // `retryAssistantCall` only threads `signal` through its own
456
- // backoff sleep, never into an in-flight `produce`. Fork `streamTurn`
457
- // as its own fiber and interrupt it on `signal` so a cancellation
458
- // reaches an active call too, exactly like interrupting this fiber
459
- // reached `streamTurn` before retry wrapped it.
460
- const produce = async () => {
461
- if (latest !== undefined) {
462
- const index = context.indexOf(latest.message);
463
- if (index !== -1)
464
- context.splice(index, 1);
465
- }
466
- const fiber = runFork(streamTurn(input, context, run, turn, emit, ++attempt));
467
- const interrupt = () => {
468
- runFork(Fiber.interrupt(fiber));
469
- };
470
- if (signal.aborted)
471
- interrupt();
472
- else
473
- signal.addEventListener("abort", interrupt, { once: true });
474
- const exit = await runPromise(Fiber.await(fiber));
475
- signal.removeEventListener("abort", interrupt);
476
- // `streamTurn`'s own Effect failures (a transport exception, a
477
- // listener failure, or this fiber's own interruption) are not
478
- // Pi-protocol error messages for `retryAssistantCall` to classify:
479
- // unwrap and rethrow the original so the surrounding
480
- // `Effect.tryPromise` reports it unchanged, not behind a new
481
- // "Pi retry loop failed" layer.
482
- if (Exit.isFailure(exit)) {
483
- const error = Cause.findErrorOption(exit.cause);
484
- throw Option.isSome(error) ? error.value : Cause.squash(exit.cause);
485
- }
486
- latest = exit.value;
487
- return latest.message;
488
- };
489
- return retryAssistantCall(produce, input.retry, signal, callbacks);
490
- },
491
- catch: (cause) => isMuleError(cause)
492
- ? cause
493
- : failure("model", "Pi retry loop failed", cause),
494
- });
495
- // `produce` always runs at least once before `retryAssistantCall`
496
- // settles, so `latest` is always set here.
497
- if (latest === undefined)
498
- return yield* Effect.die("streamTurnWithRetry: retryAssistantCall settled without calling produce");
499
- const settled = latest;
500
- if (result !== settled.message) {
501
- // Pi normalizes an abort during backoff sleep into a fresh
502
- // AssistantMessage (the failed attempt's content, `stopReason`
503
- // forced to "aborted") instead of replaying `produce`. Reconcile the
504
- // dangling failed entry `context` is still holding with that value.
505
- const index = context.indexOf(settled.message);
506
- if (index !== -1)
507
- context[index] = result;
508
- }
509
- return { identity: settled.identity, message: result };
470
+ attempt: retries,
471
+ });
472
+ }
473
+ }).pipe(Effect.onInterrupt(() => retries === 0 || finished ? Effect.void : finish(false, lastError)));
510
474
  });
511
475
  }
512
476
  const statusFor = (message) => message.stopReason === "aborted"
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Message, Tool, ToolCall } from "@earendil-works/pi-ai";
2
+ export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
2
3
  import type { MuleCompletedTurn, MuleEvent, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
3
4
  export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
4
5
  /** Promise listener adapted once into Mule's canonical Effect program. */
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { observeProviderStatus, } from "./provider-status.js";
1
2
  import * as Cause from "effect/Cause";
2
3
  import * as Effect from "effect/Effect";
3
4
  import * as Exit from "effect/Exit";
@@ -0,0 +1,25 @@
1
+ import type { SimpleStreamOptions } from "@earendil-works/pi-ai";
2
+ /** Bounded provider evidence, never response bodies, headers or error prose. */
3
+ export type ProviderRejection = Readonly<{
4
+ code: "usage-limit-reached";
5
+ }> | Readonly<{
6
+ code: "provider_unavailable";
7
+ status?: number;
8
+ }> | Readonly<{
9
+ code: "provider_rejected";
10
+ status: number;
11
+ }>;
12
+ /** One provider call's transport and parsed-event observations. */
13
+ export type ProviderStatusRecorder = Readonly<{
14
+ /** Request-local fetch observer, retaining the supplied transport. */
15
+ fetch: typeof globalThis.fetch;
16
+ /** Pi's maintained parsed-event hook, composed with the supplied observer. */
17
+ onProviderStreamEvent: NonNullable<SimpleStreamOptions["onProviderStreamEvent"]>;
18
+ /** Latest typed failure evidence for this call, or no observed failure. */
19
+ rejection: () => ProviderRejection | undefined;
20
+ }>;
21
+ /**
22
+ * Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
23
+ * decoding; this module retains no provider prose.
24
+ */
25
+ export declare function observeProviderStatus(options?: Pick<SimpleStreamOptions, "fetch" | "onProviderStreamEvent">): ProviderStatusRecorder;
@@ -0,0 +1,94 @@
1
+ import { Option, Schema } from "effect";
2
+ const usageLimitCode = "subscription_sharing_usage_limit_exceeded";
3
+ const providerError = Schema.Struct({
4
+ error: Schema.Struct({
5
+ code: Schema.String,
6
+ }),
7
+ });
8
+ const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
9
+ type: Schema.Literal("response.failed"),
10
+ response: providerError,
11
+ }));
12
+ const decodeErrorReply = Schema.decodeUnknownOption(Schema.fromJsonString(providerError));
13
+ const decodeBodyChunk = Schema.decodeUnknownSync(Schema.instanceOf(Uint8Array));
14
+ const errorBodyBytes = 16 * 1024;
15
+ /** Pi's pinned provider-request transient classes, kept as typed evidence. */
16
+ const transientStatus = (status) => status === 408 ||
17
+ status === 409 ||
18
+ status === 429 ||
19
+ (status >= 500 && status <= 599);
20
+ /** Retain Local's bounded error-copy observation; Pi still owns its response. */
21
+ async function providerErrorCode(response) {
22
+ const body = response.clone().body;
23
+ if (body === null)
24
+ return undefined;
25
+ const reader = body.getReader();
26
+ const decoder = new TextDecoder();
27
+ let bytes = 0;
28
+ let text = "";
29
+ try {
30
+ for (;;) {
31
+ const chunk = await reader.read();
32
+ if (chunk.done)
33
+ break;
34
+ const value = decodeBodyChunk(chunk.value);
35
+ bytes += value.byteLength;
36
+ if (bytes > errorBodyBytes)
37
+ return undefined;
38
+ text += decoder.decode(value, { stream: true });
39
+ }
40
+ text += decoder.decode();
41
+ const decoded = decodeErrorReply(text);
42
+ return Option.isSome(decoded) ? decoded.value.error.code : undefined;
43
+ }
44
+ finally {
45
+ // A tee's cancellation may await the original consumer. Do not hold Pi's
46
+ // response hostage to that consumer; dispose only our observation branch.
47
+ void reader.cancel().catch((cause) => {
48
+ console.error("Provider error-copy cancellation failed", cause);
49
+ });
50
+ reader.releaseLock();
51
+ }
52
+ }
53
+ /**
54
+ * Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
55
+ * decoding; this module retains no provider prose.
56
+ */
57
+ export function observeProviderStatus(options) {
58
+ let rejection;
59
+ const upstream = options?.fetch ?? globalThis.fetch;
60
+ const observed = async (input, init) => {
61
+ rejection = undefined;
62
+ let response;
63
+ try {
64
+ response = await upstream(input, init);
65
+ }
66
+ catch (cause) {
67
+ if (init?.signal?.aborted !== true)
68
+ rejection = { code: "provider_unavailable" };
69
+ throw cause;
70
+ }
71
+ const status = response.status;
72
+ const code = status === 429 ? await providerErrorCode(response) : undefined;
73
+ rejection =
74
+ status < 400 || status > 599
75
+ ? undefined
76
+ : code === usageLimitCode
77
+ ? { code: "usage-limit-reached" }
78
+ : transientStatus(status) && code !== "insufficient_quota"
79
+ ? { code: "provider_unavailable", status }
80
+ : { code: "provider_rejected", status };
81
+ return response;
82
+ };
83
+ return {
84
+ fetch: Object.assign(observed, upstream),
85
+ onProviderStreamEvent: async (data, model) => {
86
+ const failed = decodeFailedEvent(data);
87
+ if (Option.isSome(failed) &&
88
+ failed.value.response.error.code === usageLimitCode)
89
+ rejection = { code: "usage-limit-reached" };
90
+ await options?.onProviderStreamEvent?.(data, model);
91
+ },
92
+ rejection: () => rejection,
93
+ };
94
+ }
package/dist/testing.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { type FauxProviderHandle, type FauxResponseStep, type RegisterFauxProviderOptions } from "@earendil-works/pi-ai/providers/faux";
2
- import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
3
3
  /** Deterministic model and stream with Pi's observable response state. */
4
4
  export type FauxModel = Readonly<{
5
5
  /** Model supplied to Mule's run input. */
6
6
  model: Model<Api>;
7
7
  /** Bound Pi faux stream; never calls a remote provider. */
8
8
  stream: FauxProviderHandle["provider"]["streamSimple"];
9
+ /** Scripted replies have no typed provider failure; opt into retry explicitly. */
10
+ retryable: (message: AssistantMessage) => boolean;
9
11
  /** Replace the responses consumed by subsequent assistant calls. */
10
12
  setResponses: (replies: readonly FauxResponseStep[]) => void;
11
13
  /** Pi's call history and counters for deterministic assertions. */
package/dist/testing.js CHANGED
@@ -6,6 +6,7 @@ export function fauxModel(replies, options = {}) {
6
6
  return {
7
7
  model: faux.getModel(),
8
8
  stream: faux.provider.streamSimple.bind(faux.provider),
9
+ retryable: () => false,
9
10
  setResponses: (responses) => {
10
11
  faux.setResponses([...responses]);
11
12
  },
@@ -39,7 +39,7 @@ type MuleRetryObservation =
39
39
  };
40
40
  ```
41
41
 
42
- One observed Pi-retry event around a single assistant call.
42
+ One observed typed-retry event around a single assistant call.
43
43
 
44
44
  ---
45
45
 
@@ -273,6 +273,24 @@ Run Pi's model/tool loop inside Effect without changing Pi's values.
273
273
 
274
274
  ## References
275
275
 
276
+ ### observeProviderStatus
277
+
278
+ Re-exports [observeProviderStatus](index.md#observeproviderstatus)
279
+
280
+ ---
281
+
282
+ ### ProviderRejection
283
+
284
+ Re-exports [ProviderRejection](index.md#providerrejection)
285
+
286
+ ---
287
+
288
+ ### ProviderStatusRecorder
289
+
290
+ Re-exports [ProviderStatusRecorder](index.md#providerstatusrecorder)
291
+
292
+ ---
293
+
276
294
  ### MuleCompletedTurn
277
295
 
278
296
  Re-exports [MuleCompletedTurn](index.md#mulecompletedturn)
package/docs/api/index.md CHANGED
@@ -131,12 +131,14 @@ Result produced by a tool before Mule forms Pi's correlated result message.
131
131
  readonly content: (TextContent | ImageContent)[];
132
132
  ```
133
133
 
134
- ##### details?
134
+ ##### data?
135
135
 
136
136
  ```ts
137
- readonly optional details?: JsonValue;
137
+ readonly optional data?: JsonValue;
138
138
  ```
139
139
 
140
+ Application payload for listeners, never part of a model message.
141
+
140
142
  ##### usage?
141
143
 
142
144
  ```ts
@@ -276,6 +278,7 @@ type MuleEvent =
276
278
  turn: MuleIdentity;
277
279
  message: MuleIdentity;
278
280
  value: Message;
281
+ data?: JsonValue;
279
282
  }
280
283
  | {
281
284
  type: "tool_execution_start";
@@ -403,9 +406,19 @@ Lifecycle emitted around Pi values. Pi messages and events are not rewritten.
403
406
  turn: MuleIdentity;
404
407
  message: MuleIdentity;
405
408
  value: Message;
409
+ data?: JsonValue;
406
410
  }
407
411
  ```
408
412
 
413
+ | Name | Type | Description |
414
+ | --------- | ------------------------------- | --------------------------------------------------------------- |
415
+ | `type` | `"message_end"` | - |
416
+ | `run` | [`MuleIdentity`](#muleidentity) | - |
417
+ | `turn` | [`MuleIdentity`](#muleidentity) | - |
418
+ | `message` | [`MuleIdentity`](#muleidentity) | - |
419
+ | `value` | `Message` | - |
420
+ | `data?` | `JsonValue` | Application payload independent of the completed model message. |
421
+
409
422
  ---
410
423
 
411
424
  ##### Type Literal
@@ -628,10 +641,10 @@ Continue a durable, completed assistant turn before requesting another response.
628
641
  ##### streamOptions?
629
642
 
630
643
  ```ts
631
- readonly optional streamOptions?: Omit<SimpleStreamOptions, "signal">;
644
+ readonly optional streamOptions?: Omit<SimpleStreamOptions, "signal" | "maxRetries">;
632
645
  ```
633
646
 
634
- Pi stream options. Cancellation is the run's interruption, never a signal here.
647
+ Provider-native retries are disabled; the typed host policy owns retry.
635
648
 
636
649
  ##### modelStreamIdleTimeoutMs?
637
650
 
@@ -653,9 +666,30 @@ readonly optional toolExecution?: "parallel" | "sequential";
653
666
  readonly optional retry?: RetryPolicy;
654
667
  ```
655
668
 
656
- Pi's bounded retry policy around each assistant call (`retryAssistantCall`).
669
+ Bounded retry around each assistant call, using Pi's exponential delay.
657
670
  Undefined means no retry, matching pi-ai's own default. Hosts that want Pi
658
- coding-agent's numbers pass `piCodingAgentRetryDefaults`.
671
+ coding-agent's numbers pass `piCodingAgentRetryDefaults`. Enabled policy counts
672
+ and delays must be non-negative safe integers. Each assistant turn captures its
673
+ policy before requesting the model.
674
+
675
+ ##### retryable
676
+
677
+ ```ts
678
+ readonly retryable: (message) => boolean;
679
+ ```
680
+
681
+ Host-owned typed decision for this exact assistant message. Only true permits
682
+ retry; unknown messages return false, never a decision from prose.
683
+
684
+ ###### Parameters
685
+
686
+ | Parameter | Type |
687
+ | --------- | ------------------ |
688
+ | `message` | `AssistantMessage` |
689
+
690
+ ###### Returns
691
+
692
+ `boolean`
659
693
 
660
694
  ##### retryAfterMs?
661
695
 
@@ -878,6 +912,43 @@ the run, or with the abort reason (an `AbortError` when the reason is not an
878
912
 
879
913
  `Promise`\<[`MuleRunOutcome`](#mulerunoutcome)\>
880
914
 
915
+ ---
916
+
917
+ ### ProviderRejection
918
+
919
+ ```ts
920
+ type ProviderRejection =
921
+ | Readonly<{
922
+ code: "usage-limit-reached";
923
+ }>
924
+ | Readonly<{
925
+ code: "provider_unavailable";
926
+ status?: number;
927
+ }>
928
+ | Readonly<{
929
+ code: "provider_rejected";
930
+ status: number;
931
+ }>;
932
+ ```
933
+
934
+ Bounded provider evidence, never response bodies, headers or error prose.
935
+
936
+ ---
937
+
938
+ ### ProviderStatusRecorder
939
+
940
+ ```ts
941
+ type ProviderStatusRecorder = Readonly<{
942
+ fetch: typeof globalThis.fetch;
943
+ onProviderStreamEvent: NonNullable<
944
+ SimpleStreamOptions["onProviderStreamEvent"]
945
+ >;
946
+ rejection: () => ProviderRejection | undefined;
947
+ }>;
948
+ ```
949
+
950
+ One provider call's transport and parsed-event observations.
951
+
881
952
  ## Variables
882
953
 
883
954
  ### mule
@@ -887,3 +958,24 @@ const mule: Mule;
887
958
  ```
888
959
 
889
960
  Default Promise-facing Mule interface.
961
+
962
+ ## Functions
963
+
964
+ ### observeProviderStatus()
965
+
966
+ ```ts
967
+ function observeProviderStatus(options?): ProviderStatusRecorder;
968
+ ```
969
+
970
+ Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
971
+ decoding; this module retains no provider prose.
972
+
973
+ #### Parameters
974
+
975
+ | Parameter | Type |
976
+ | ---------- | ----------------------------------------------------------------------- |
977
+ | `options?` | `Pick`\<`SimpleStreamOptions`, `"onProviderStreamEvent"` \| `"fetch"`\> |
978
+
979
+ #### Returns
980
+
981
+ [`ProviderStatusRecorder`](#providerstatusrecorder)
@@ -16,6 +16,7 @@
16
16
  type FauxModel = Readonly<{
17
17
  model: Model<Api>;
18
18
  stream: FauxProviderHandle["provider"]["streamSimple"];
19
+ retryable: (message) => boolean;
19
20
  setResponses: (replies) => void;
20
21
  state: FauxProviderHandle["state"];
21
22
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/mule",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Effect-native Pi model and tool loop",
6
6
  "type": "module",
@@ -28,9 +28,10 @@
28
28
  "THIRD_PARTY_NOTICES"
29
29
  ],
30
30
  "dependencies": {
31
- "@earendil-works/pi-ai": "0.99.2",
32
- "@fungi.computer/cairn": "0.0.1",
33
- "effect": "4.0.2"
31
+ "@earendil-works/pi-ai": "1.3.0",
32
+ "@fungi.computer/cairn": "0.1.0",
33
+ "effect": "4.0.2",
34
+ "typebox": "1.3.27"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/node": "24.13.3",