@fungi.computer/mule 0.2.1 → 0.2.3

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
@@ -12,10 +12,10 @@ npm install @fungi.computer/mule
12
12
  ```
13
13
 
14
14
  The package ships ESM only and depends on `effect` and Pi
15
- (`@earendil-works/pi-ai`) at runtime, plus `@fungi.computer/cairn` for IDs. Mule
16
- does not require or install the MCP SDK. Consumers that type-check dependency
17
- declarations (`skipLibCheck: false`) must install Pi’s Google GenAI optional
18
- declaration dependency themselves:
15
+ (`@earendil-works/pi-ai`) at runtime, plus TypeBox and `@fungi.computer/cairn`
16
+ for IDs. Mule does not require or install the MCP SDK. Consumers that type-check
17
+ dependency declarations (`skipLibCheck: false`) must install Pi’s Google GenAI
18
+ optional declaration dependency themselves:
19
19
 
20
20
  ```sh
21
21
  npm install --save-dev @modelcontextprotocol/sdk
@@ -80,6 +80,10 @@ export type MuleEvent = {
80
80
  readonly turn: MuleIdentity;
81
81
  readonly message: MuleIdentity;
82
82
  readonly value: Message;
83
+ /** Commit the assistant and its retry decision together before observing retry. */
84
+ readonly retry?: Exclude<MuleRetryObservation, {
85
+ type: "retry_attempt_started";
86
+ }> | undefined;
83
87
  /** Application payload independent of the completed model message. */
84
88
  readonly data?: JsonValue | undefined;
85
89
  } | {
@@ -135,11 +139,24 @@ export type MuleToolCheckpoint = {
135
139
  /** Present when the transcript message was committed too. */
136
140
  readonly message?: ToolResultMessage | undefined;
137
141
  };
138
- /** Completed assistant turn recovered by the transcript owner, before its next model call. */
139
- export type MuleResume = {
140
- readonly message: AssistantMessage;
141
- readonly tools: readonly MuleToolCheckpoint[];
142
- };
142
+ /** Transcript-owned continuation at a completed assistant or model retry boundary. */
143
+ export type MuleResume = Readonly<{
144
+ kind: "assistant";
145
+ message: AssistantMessage;
146
+ tools: readonly MuleToolCheckpoint[];
147
+ }> | (Readonly<{
148
+ kind: "retry";
149
+ /** Number of the retry waiting or already dispatched; zero is the initial request. */
150
+ attempt: number;
151
+ /** The previously recorded retry ceiling, when a retry has been scheduled. */
152
+ maxAttempts?: number;
153
+ message?: AssistantMessage;
154
+ }> & (Readonly<{
155
+ phase: "waiting";
156
+ dueAt: number;
157
+ }> | Readonly<{
158
+ phase: "dispatched";
159
+ }>));
143
160
  /** Run inputs that are identical on the Promise and Effect roots. */
144
161
  export type MuleRunSettings = {
145
162
  readonly model: Model<Api>;
@@ -185,6 +202,7 @@ export type MuleRetryObservation = {
185
202
  readonly attempt: number;
186
203
  readonly maxAttempts: number;
187
204
  readonly delayMs: number;
205
+ readonly dueAt: number;
188
206
  readonly errorMessage: string;
189
207
  } | {
190
208
  readonly type: "retry_attempt_started";
package/dist/effect.d.ts CHANGED
@@ -11,7 +11,7 @@ export { Type } from "typebox";
11
11
  export type { MuleRetryObservation } from "./contract.js";
12
12
  /**
13
13
  * Pi coding-agent's retry defaults (`settings-manager.js` `getRetrySettings()`,
14
- * `@earendil-works/pi-ai` 0.99.0, the version pinned in the lockfile). Pi-ai's
14
+ * `@earendil-works/pi-ai` 1.3.0, the version pinned in the lockfile). Pi-ai's
15
15
  * own `RetryPolicy` has no default — an undefined policy means no retry — so
16
16
  * a host that wants Pi coding-agent's numbers passes this explicitly.
17
17
  */
@@ -40,6 +40,8 @@ export type MuleListener = (event: MuleEvent) => Effect.Effect<void, MuleError>;
40
40
  * fiber; the run then ends with a `cancelled` `run_end`.
41
41
  */
42
42
  export type MuleRunInput = MuleRunSettings & {
43
+ /** Notify the owner when provider cleanup was detached or failed; abort alone is not drain evidence. */
44
+ readonly onUndrained?: Effect.Effect<void> | undefined;
43
45
  /** Rebuild the model system prompt immediately before each provider request. */
44
46
  readonly prepareSystemPrompt?: (() => Effect.Effect<string, MuleError>) | undefined;
45
47
  readonly tools?: readonly MuleTool[] | undefined;
package/dist/effect.js CHANGED
@@ -20,7 +20,7 @@ import * as Stream from "effect/Stream";
20
20
  export { Type } from "typebox";
21
21
  /**
22
22
  * Pi coding-agent's retry defaults (`settings-manager.js` `getRetrySettings()`,
23
- * `@earendil-works/pi-ai` 0.99.0, the version pinned in the lockfile). Pi-ai's
23
+ * `@earendil-works/pi-ai` 1.3.0, the version pinned in the lockfile). Pi-ai's
24
24
  * own `RetryPolicy` has no default — an undefined policy means no retry — so
25
25
  * a host that wants Pi coding-agent's numbers passes this explicitly.
26
26
  */
@@ -279,9 +279,35 @@ function streamTurn(input, context, run, turn, emit, attempt) {
279
279
  return withModelStreamDeadline(effect, Math.max(0, Math.min(wholeRemaining, gapRemaining)), () => limitError(kind).message);
280
280
  };
281
281
  const controller = new AbortController();
282
- yield* Effect.addFinalizer(() => Effect.sync(() => {
283
- controller.abort("Mule model scope closed");
284
- }));
282
+ const provider = {};
283
+ yield* Effect.addFinalizer(() => Effect.tryPromise({
284
+ try: () => {
285
+ controller.abort("Mule model scope closed");
286
+ if (provider.response === undefined)
287
+ return Promise.resolve(true);
288
+ // Iterator.return only closes the consumer. Pi's result is the
289
+ // producer's terminal, including an abort-aware provider's error.
290
+ const cleanup = provider.response.then(async (response) => {
291
+ await Promise.all([
292
+ provider.iterator?.return?.(),
293
+ response.result(),
294
+ ]);
295
+ }, () => undefined);
296
+ return new Promise((resolve) => {
297
+ const timer = setTimeout(() => {
298
+ resolve(false);
299
+ }, providerIteratorReleaseGraceMs);
300
+ cleanup.then(() => {
301
+ clearTimeout(timer);
302
+ resolve(true);
303
+ }, () => {
304
+ clearTimeout(timer);
305
+ resolve(false);
306
+ });
307
+ });
308
+ },
309
+ catch: (cause) => failure("model", "Pi model stream failed", cause),
310
+ }).pipe(Effect.orElseSucceed(() => false), Effect.flatMap((released) => released ? Effect.void : (input.onUndrained ?? Effect.void))));
285
311
  const resolvedApiKey = (input.resolveApiKey === undefined
286
312
  ? undefined
287
313
  : yield* input.resolveApiKey(input.model.provider)) ??
@@ -306,30 +332,15 @@ function streamTurn(input, context, run, turn, emit, attempt) {
306
332
  if (descriptors.length > 0)
307
333
  piContext.tools = descriptors;
308
334
  const response = yield* deadline(Effect.tryPromise({
309
- try: async () => {
335
+ try: () => {
310
336
  const stream = input.stream ?? streamPi;
311
- return stream(input.model, normalizeContext(piContext), options);
337
+ provider.response = Promise.resolve(stream(input.model, normalizeContext(piContext), options));
338
+ return provider.response;
312
339
  },
313
340
  catch: (cause) => failure("model", "Pi model stream could not start", cause),
314
341
  }));
315
- const iterator = yield* Effect.acquireRelease(Effect.sync(() => response[Symbol.asyncIterator]()), (active) => Effect.tryPromise({
316
- try: () => {
317
- controller.abort("Mule model stream closed");
318
- // Provider iterators can ignore abort while a read is pending.
319
- // Releasing a timed-out run must not await that iterator forever.
320
- return new Promise((resolve) => {
321
- const timer = setTimeout(resolve, providerIteratorReleaseGraceMs);
322
- Promise.resolve(active.return?.()).then(() => {
323
- clearTimeout(timer);
324
- resolve();
325
- }, () => {
326
- clearTimeout(timer);
327
- resolve();
328
- });
329
- });
330
- },
331
- catch: (cause) => failure("model", "Pi model stream failed", cause),
332
- }).pipe(Effect.ignore));
342
+ const iterator = response[Symbol.asyncIterator]();
343
+ provider.iterator = iterator;
333
344
  const observeEvent = (event) => {
334
345
  const now = Date.now();
335
346
  maxGapMs = Math.max(maxGapMs, now - lastEventAt);
@@ -409,14 +420,7 @@ function streamTurn(input, context, run, turn, emit, attempt) {
409
420
  value: completed,
410
421
  });
411
422
  }
412
- yield* emit({
413
- type: "message_end",
414
- run,
415
- turn,
416
- message: messageIdentity,
417
- value: completed,
418
- });
419
- return { identity: messageIdentity, message: completed };
423
+ return { message: completed, identity: messageIdentity };
420
424
  }).pipe(Effect.catch((error) => {
421
425
  if (error.cause !== modelLimitCause)
422
426
  return Effect.fail(error);
@@ -459,13 +463,6 @@ function streamTurn(input, context, run, turn, emit, attempt) {
459
463
  }
460
464
  else
461
465
  context[context.length - 1] = value;
462
- yield* emit({
463
- type: "message_end",
464
- run,
465
- turn,
466
- message: messageIdentity,
467
- value,
468
- });
469
466
  return {
470
467
  identity: messageIdentity,
471
468
  message: value,
@@ -483,7 +480,7 @@ function streamTurn(input, context, run, turn, emit, attempt) {
483
480
  * failed attempt's message is removed from `context` before the next
484
481
  * attempt runs, so it is never replayed into the model's next call.
485
482
  */
486
- function streamTurnWithRetry(input, context, run, turn, emit) {
483
+ function streamTurnWithRetry(input, context, run, turn, emit, resume) {
487
484
  return Effect.gen(function* () {
488
485
  // One turn owns one finite budget, even if its caller updates future policy.
489
486
  const policy = input.retry === undefined ? undefined : { ...input.retry };
@@ -495,10 +492,13 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
495
492
  ].some((value) => !Number.isSafeInteger(value) || value < 0))
496
493
  return yield* Effect.fail(failure("model", "Retry policy requires non-negative safe-integer counts and delays"));
497
494
  let latest;
498
- let calls = 0;
499
- let retries = 0;
495
+ const maxRetries = Math.min(policy?.maxRetries ?? 0, resume?.maxAttempts ?? Infinity);
496
+ let calls = resume === undefined
497
+ ? 0
498
+ : resume.attempt + (resume.phase === "dispatched" ? 1 : 0);
499
+ let retries = resume?.attempt ?? 0;
500
500
  let finished = false;
501
- let lastError;
501
+ let lastError = resume?.message?.errorMessage;
502
502
  const reportRetry = (event) => input.onRetry === undefined ? Effect.void : input.onRetry(event);
503
503
  const finish = (success, finalError) => Effect.gen(function* () {
504
504
  finished = true;
@@ -513,66 +513,130 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
513
513
  finalError,
514
514
  });
515
515
  });
516
+ const waitForRetry = (dueAt) => Effect.gen(function* () {
517
+ yield* Effect.sleep(Math.max(0, dueAt - Date.now()));
518
+ yield* reportRetry({
519
+ type: "retry_attempt_started",
520
+ run,
521
+ turn,
522
+ attempt: retries,
523
+ });
524
+ });
525
+ const prepareRetry = (errorMessage, after) => Effect.gen(function* () {
526
+ if (policy === undefined)
527
+ return yield* Effect.fail(failure("model", "Retry policy is unavailable"));
528
+ retries++;
529
+ lastError = errorMessage;
530
+ const minimumDelayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
531
+ Number.isSafeInteger(after) &&
532
+ after >= 0 &&
533
+ after <= 60_000
534
+ ? after
535
+ : 0);
536
+ // Add rather than subtract jitter: a provider's Retry-After is a floor.
537
+ // Jitter stops at one minute; a longer explicit host policy stays intact.
538
+ const jitterMs = Math.min(1_000, minimumDelayMs / 4, Math.max(0, 60_000 - minimumDelayMs));
539
+ const delayMs = minimumDelayMs + Math.floor(Math.random() * jitterMs);
540
+ const dueAt = Date.now() + delayMs;
541
+ return {
542
+ type: "retry_scheduled",
543
+ run,
544
+ turn,
545
+ attempt: retries,
546
+ maxAttempts: maxRetries,
547
+ delayMs,
548
+ dueAt,
549
+ errorMessage: lastError,
550
+ };
551
+ });
516
552
  return yield* Effect.gen(function* () {
553
+ if (resume !== undefined) {
554
+ if (!Number.isSafeInteger(resume.attempt) ||
555
+ resume.attempt < 0 ||
556
+ (resume.maxAttempts !== undefined &&
557
+ (!Number.isSafeInteger(resume.maxAttempts) ||
558
+ resume.maxAttempts < 0)) ||
559
+ (resume.phase === "waiting" &&
560
+ (!Number.isSafeInteger(resume.dueAt) || resume.dueAt < 0)))
561
+ return yield* Effect.fail(failure("model", "Invalid model retry continuation"));
562
+ if (!policy?.enabled ||
563
+ retries > maxRetries ||
564
+ (resume.phase === "dispatched" && retries >= maxRetries)) {
565
+ yield* finish(false, "Model retry budget exhausted after restart");
566
+ return resume.message === undefined
567
+ ? yield* Effect.fail(failure("model", "Model retry budget exhausted after restart"))
568
+ : { message: resume.message };
569
+ }
570
+ if (resume.phase === "waiting")
571
+ yield* waitForRetry(resume.dueAt);
572
+ else {
573
+ // A restart is not provider backpressure: charge the attempt, resend now.
574
+ const retry = {
575
+ ...(yield* prepareRetry("Model request interrupted by a restart")),
576
+ delayMs: 0,
577
+ dueAt: Date.now(),
578
+ };
579
+ yield* reportRetry(retry);
580
+ yield* waitForRetry(retry.dueAt);
581
+ }
582
+ }
517
583
  for (;;) {
518
584
  if (latest !== undefined) {
519
585
  const index = context.indexOf(latest.message);
520
586
  if (index !== -1)
521
587
  context.splice(index, 1);
522
588
  }
523
- latest = yield* streamTurn(input, context, run, turn, emit, ++calls);
524
- const message = latest.message;
525
- if (message.stopReason !== "error" ||
526
- policy === undefined ||
527
- !policy.enabled ||
528
- retries >= policy.maxRetries) {
529
- if (retries > 0) {
530
- yield* finish(message.stopReason !== "error" &&
531
- message.stopReason !== "aborted", message.stopReason === "error" ? message.errorMessage : undefined);
589
+ const streamed = yield* streamTurn(input, context, run, turn, emit, ++calls);
590
+ latest = streamed;
591
+ const message = streamed.message;
592
+ let retry;
593
+ if (message.stopReason === "error" &&
594
+ policy?.enabled &&
595
+ retries < maxRetries) {
596
+ const retryable = yield* Effect.try({
597
+ try: () => streamed.limitReached === true || input.retryable(message),
598
+ catch: (cause) => failure("model", "Typed model retry decision failed", cause),
599
+ });
600
+ if (retryable) {
601
+ const after = yield* Effect.try({
602
+ try: () => input.retryAfterMs?.(message),
603
+ catch: (cause) => failure("model", "Model retry delay observation failed", cause),
604
+ });
605
+ retry = yield* prepareRetry(message.errorMessage ?? "Unknown error", after);
532
606
  }
533
- return latest;
534
607
  }
535
- const retryable = yield* Effect.try({
536
- try: () => latest?.limitReached === true || input.retryable(message),
537
- catch: (cause) => failure("model", "Typed model retry decision failed", cause),
538
- });
539
- if (!retryable) {
540
- if (retries > 0)
541
- yield* finish(false, message.errorMessage);
542
- return latest;
608
+ if (retry === undefined &&
609
+ (message.stopReason === "error" || retries > 0)) {
610
+ const terminal = {
611
+ type: "retry_finished",
612
+ run,
613
+ turn,
614
+ attempt: retries,
615
+ success: message.stopReason !== "error" &&
616
+ message.stopReason !== "aborted",
617
+ };
618
+ retry =
619
+ message.stopReason === "error" && message.errorMessage !== undefined
620
+ ? { ...terminal, finalError: message.errorMessage }
621
+ : terminal;
543
622
  }
544
- retries++;
545
- lastError = message.errorMessage ?? "Unknown error";
546
- const after = yield* Effect.try({
547
- try: () => input.retryAfterMs?.(message),
548
- catch: (cause) => failure("model", "Model retry delay observation failed", cause),
549
- });
550
- const minimumDelayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
551
- Number.isSafeInteger(after) &&
552
- after >= 0 &&
553
- after <= 60_000
554
- ? after
555
- : 0);
556
- // Add rather than subtract jitter: a provider's Retry-After is a floor.
557
- // Jitter stops at one minute; a longer explicit host policy stays intact.
558
- const jitterMs = Math.min(1_000, minimumDelayMs / 4, Math.max(0, 60_000 - minimumDelayMs));
559
- const delayMs = minimumDelayMs + Math.floor(Math.random() * jitterMs);
560
- yield* reportRetry({
561
- type: "retry_scheduled",
562
- run,
563
- turn,
564
- attempt: retries,
565
- maxAttempts: policy.maxRetries,
566
- delayMs,
567
- errorMessage: lastError,
568
- });
569
- yield* Effect.sleep(delayMs);
570
- yield* reportRetry({
571
- type: "retry_attempt_started",
623
+ // The recorder receives one decision boundary. No await can expose a
624
+ // completed error without the retry deadline (or terminal decision).
625
+ yield* emit({
626
+ type: "message_end",
572
627
  run,
573
628
  turn,
574
- attempt: retries,
629
+ message: streamed.identity,
630
+ value: message,
631
+ retry,
575
632
  });
633
+ if (retry?.type === "retry_finished")
634
+ finished = true;
635
+ if (retry !== undefined && retries > 0)
636
+ yield* reportRetry(retry);
637
+ if (retry?.type !== "retry_scheduled")
638
+ return latest;
639
+ yield* waitForRetry(retry.dueAt);
576
640
  }
577
641
  }).pipe(Effect.onInterrupt(() => retries === 0 || finished ? Effect.void : finish(false, lastError)));
578
642
  });
@@ -603,11 +667,11 @@ function emitPendingMessages(messages, context, newMessages, run, turn, emit) {
603
667
  });
604
668
  })).pipe(Effect.asVoid);
605
669
  }
606
- function completeLoopTurn(input, context, newMessages, run, turn, pending, emit) {
670
+ function completeLoopTurn(input, context, newMessages, run, turn, pending, emit, retryResume) {
607
671
  return Effect.gen(function* () {
608
672
  yield* emit({ type: "turn_start", run, turn });
609
673
  yield* emitPendingMessages(pending, context, newMessages, run, turn, emit);
610
- const streamed = yield* streamTurnWithRetry(input, context, run, turn, emit);
674
+ const streamed = yield* streamTurnWithRetry(input, context, run, turn, emit, retryResume);
611
675
  // Pi keeps partially streamed tool calls on an aborted or errored message.
612
676
  // Like Pi's loop, the turn ends there: nothing executes and no tool result
613
677
  // pretends a call ran. The run then settles with the honest terminal.
@@ -800,7 +864,8 @@ function runLoop(input, emit, runIdentity, terminalIdentity) {
800
864
  let finalMessage;
801
865
  let continueLoop;
802
866
  yield* emit({ type: "run_start", run: runIdentity });
803
- if (input.resume !== undefined) {
867
+ let retryResume = input.resume?.kind === "retry" ? input.resume : undefined;
868
+ if (input.resume?.kind === "assistant") {
804
869
  const resumed = yield* resumeTurn(input, input.resume, context, newMessages, runIdentity, emit);
805
870
  const status = statusFor(resumed.message);
806
871
  const next = status === "completed"
@@ -811,7 +876,8 @@ function runLoop(input, emit, runIdentity, terminalIdentity) {
811
876
  pending.push(...next.pending);
812
877
  }
813
878
  do {
814
- const completed = yield* completeLoopTurn(input, context, newMessages, runIdentity, identity("turn", runIdentity), pending, emit);
879
+ const completed = yield* completeLoopTurn(input, context, newMessages, runIdentity, identity("turn", runIdentity), pending, emit, retryResume);
880
+ retryResume = undefined;
815
881
  finalMessage = completed.message;
816
882
  const status = statusFor(completed.message);
817
883
  if (status !== "completed") {
@@ -11,6 +11,12 @@ const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
11
11
  response: providerError,
12
12
  }));
13
13
  const decodeErrorReply = Schema.decodeUnknownOption(Schema.fromJsonString(providerError));
14
+ const decodeErrorEvent = Schema.decodeUnknownOption(Schema.Struct({
15
+ type: Schema.Literal("error"),
16
+ status: Schema.optional(Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 100, maximum: 599 }))),
17
+ code: Schema.optional(Schema.String),
18
+ error: Schema.optional(providerError.fields.error),
19
+ }));
14
20
  const decodeBodyChunk = Schema.decodeUnknownSync(Schema.instanceOf(Uint8Array));
15
21
  const errorBodyBytes = 16 * 1024;
16
22
  const decodeSeconds = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isBetween({ minimum: 0, maximum: 60 })));
@@ -24,7 +30,9 @@ const decodeLimit = Schema.decodeUnknownOption(Schema.Literals([
24
30
  const decodeRejected = Schema.decodeUnknownOption(Schema.Literal("rejected"));
25
31
  const decodeExhausted = Schema.decodeUnknownOption(Schema.Literal("0"));
26
32
  const decodeDate = Schema.decodeUnknownOption(Schema.DateTimeUtcFromString);
27
- const decodeOverloaded = Schema.decodeUnknownOption(Schema.Struct({
33
+ const decodeOverloaded = Schema.decodeUnknownOption(
34
+ // https://docs.anthropic.com/en/api/errors#http-errors
35
+ Schema.Struct({
28
36
  type: Schema.Literal("error"),
29
37
  error: Schema.Struct({ type: Schema.Literal("overloaded_error") }),
30
38
  }));
@@ -171,6 +179,22 @@ export function observeProviderStatus(options) {
171
179
  if (Option.isSome(failed) &&
172
180
  failed.value.response.error.code === usageLimitCode)
173
181
  rejection = { code: "usage-limit-reached" };
182
+ // OpenAI's Responses error code, not an inference from overload prose:
183
+ // https://platform.openai.com/docs/api-reference/responses/object#responses/object-error
184
+ if (Option.isSome(failed) &&
185
+ failed.value.response.error.code === "server_error")
186
+ rejection = { code: "provider_unavailable" };
187
+ // WebSocket API errors retain HTTP status in the parsed event:
188
+ // https://developers.openai.com/api/docs/guides/websocket-mode/#handling-errors
189
+ const error = decodeErrorEvent(data);
190
+ if (Option.isSome(error)) {
191
+ const status = error.value.status;
192
+ if (status !== undefined && status >= 500)
193
+ rejection = { code: "provider_unavailable", status };
194
+ else if (status === undefined &&
195
+ (error.value.code ?? error.value.error?.code) === "server_error")
196
+ rejection = { code: "provider_unavailable" };
197
+ }
174
198
  if (Option.isSome(decodeOverloaded(data)))
175
199
  rejection = { code: "overloaded" };
176
200
  await options?.onProviderStreamEvent?.(data, model);
@@ -21,6 +21,7 @@ type MuleRetryObservation =
21
21
  attempt: number;
22
22
  maxAttempts: number;
23
23
  delayMs: number;
24
+ dueAt: number;
24
25
  errorMessage: string;
25
26
  }
26
27
  | {
@@ -152,6 +153,7 @@ fiber; the run then ends with a `cancelled` `run_end`.
152
153
 
153
154
  | Name | Type | Description |
154
155
  | ------------------------ | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
156
+ | `onUndrained?` | `Effect.Effect`\<`void`\> | Notify the owner when provider cleanup was detached or failed; abort alone is not drain evidence. |
155
157
  | `prepareSystemPrompt()?` | () => `Effect.Effect`\<`string`, [`MuleError`](index.md#muleerror)\> | Rebuild the model system prompt immediately before each provider request. |
156
158
  | `tools?` | readonly [`MuleTool`](#muletool)[] | - |
157
159
  | `resolveApiKey()?` | (`provider`) => `Effect.Effect`\<`string` \| `undefined`, [`MuleError`](index.md#muleerror)\> | Resolve a fresh provider credential immediately before each Pi call. |
@@ -1849,7 +1851,7 @@ const piCodingAgentRetryDefaults: RetryPolicy;
1849
1851
  ```
1850
1852
 
1851
1853
  Pi coding-agent's retry defaults (`settings-manager.js` `getRetrySettings()`,
1852
- `@earendil-works/pi-ai` 0.99.0, the version pinned in the lockfile). Pi-ai's own
1854
+ `@earendil-works/pi-ai` 1.3.0, the version pinned in the lockfile). Pi-ai's own
1853
1855
  `RetryPolicy` has no default — an undefined policy means no retry — so a host
1854
1856
  that wants Pi coding-agent's numbers passes this explicitly.
1855
1857
 
package/docs/api/index.md CHANGED
@@ -281,6 +281,12 @@ type MuleEvent =
281
281
  turn: MuleIdentity;
282
282
  message: MuleIdentity;
283
283
  value: Message;
284
+ retry?: Exclude<
285
+ MuleRetryObservation,
286
+ {
287
+ type: "retry_attempt_started";
288
+ }
289
+ >;
284
290
  data?: JsonValue;
285
291
  }
286
292
  | {
@@ -409,18 +415,22 @@ Lifecycle emitted around Pi values. Pi messages and events are not rewritten.
409
415
  turn: MuleIdentity;
410
416
  message: MuleIdentity;
411
417
  value: Message;
418
+ retry?: Exclude<MuleRetryObservation, {
419
+ type: "retry_attempt_started";
420
+ }>;
412
421
  data?: JsonValue;
413
422
  }
414
423
  ```
415
424
 
416
- | Name | Type | Description |
417
- | --------- | ---------------------------------- | --------------------------------------------------------------- |
418
- | `type` | `"message_end"` | - |
419
- | `run` | [`MuleIdentity`](#muleidentity) | - |
420
- | `turn` | [`MuleIdentity`](#muleidentity) | - |
421
- | `message` | [`MuleIdentity`](#muleidentity) | - |
422
- | `value` | [`Message`](effect.md#message) | - |
423
- | `data?` | [`JsonValue`](effect.md#jsonvalue) | Application payload independent of the completed model message. |
425
+ | Name | Type | Description |
426
+ | --------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
427
+ | `type` | `"message_end"` | - |
428
+ | `run` | [`MuleIdentity`](#muleidentity) | - |
429
+ | `turn` | [`MuleIdentity`](#muleidentity) | - |
430
+ | `message` | [`MuleIdentity`](#muleidentity) | - |
431
+ | `value` | [`Message`](effect.md#message) | - |
432
+ | `retry?` | `Exclude`\<[`MuleRetryObservation`](effect.md#muleretryobservation), \{ `type`: `"retry_attempt_started"`; \}\> | Commit the assistant and its retry decision together before observing retry. |
433
+ | `data?` | [`JsonValue`](effect.md#jsonvalue) | Application payload independent of the completed model message. |
424
434
 
425
435
  ---
426
436
 
@@ -574,25 +584,28 @@ Durable evidence for a call in an interrupted assistant turn.
574
584
  ### MuleResume
575
585
 
576
586
  ```ts
577
- type MuleResume = object;
578
- ```
579
-
580
- Completed assistant turn recovered by the transcript owner, before its next
581
- model call.
582
-
583
- #### Properties
584
-
585
- ##### message
586
-
587
- ```ts
588
- readonly message: AssistantMessage;
587
+ type MuleResume =
588
+ | Readonly<{
589
+ kind: "assistant";
590
+ message: AssistantMessage;
591
+ tools: readonly MuleToolCheckpoint[];
592
+ }>
593
+ | Readonly<{
594
+ kind: "retry";
595
+ attempt: number;
596
+ maxAttempts?: number;
597
+ message?: AssistantMessage;
598
+ }> &
599
+ | Readonly<{
600
+ phase: "waiting";
601
+ dueAt: number;
602
+ }>
603
+ | Readonly<{
604
+ phase: "dispatched";
605
+ }>;
589
606
  ```
590
607
 
591
- ##### tools
592
-
593
- ```ts
594
- readonly tools: readonly MuleToolCheckpoint[];
595
- ```
608
+ Transcript-owned continuation at a completed assistant or model retry boundary.
596
609
 
597
610
  ---
598
611
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/mule",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Effect-native Pi model and tool loop",
6
6
  "type": "module",