@fungi.computer/mule 0.1.1 → 0.2.1

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.
@@ -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,6 +1,6 @@
1
1
  export { decodeProviderData } from "./provider-data.js";
2
2
  export { streamPi as streamModel } from "./provider.js";
3
- export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
3
+ export { isRetryableProviderRejection, observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
4
4
  export { normalizeModel } from "./provider-model.js";
5
5
  import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "./types.js";
6
6
  import * as Effect from "effect/Effect";
@@ -16,12 +16,6 @@ export type { MuleRetryObservation } from "./contract.js";
16
16
  * a host that wants Pi coding-agent's numbers passes this explicitly.
17
17
  */
18
18
  export declare const piCodingAgentRetryDefaults: RetryPolicy;
19
- /**
20
- * Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
21
- * `settings-manager.js`). A host that wants Pi's number for
22
- * `modelStreamIdleTimeoutMs` passes this explicitly.
23
- */
24
- export declare const piHttpIdleTimeoutMs = 300000;
25
19
  /** Apply Pi's maintained context-overflow semantics to an exact Pi message. */
26
20
  export declare function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
27
21
  export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
package/dist/effect.js CHANGED
@@ -2,7 +2,7 @@ import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
2
2
  export { decodeProviderData } from "./provider-data.js";
3
3
  export { streamPi as streamModel } from "./provider.js";
4
4
  import { nanoid } from "@fungi.computer/cairn";
5
- export { observeProviderStatus, } from "./provider-status.js";
5
+ export { isRetryableProviderRejection, observeProviderStatus, } from "./provider-status.js";
6
6
  // Pi's root entry also evaluates its TypeBox validator compiler and model
7
7
  // stores; Mule needs only these leaf helpers, so it imports them directly and
8
8
  // keeps that work out of every host's startup.
@@ -30,18 +30,20 @@ export const piCodingAgentRetryDefaults = {
30
30
  baseDelayMs: 2000,
31
31
  maxAgentDelayMs: 60_000,
32
32
  };
33
- /**
34
- * Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
35
- * `settings-manager.js`). A host that wants Pi's number for
36
- * `modelStreamIdleTimeoutMs` passes this explicitly.
37
- */
38
- 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
+ };
39
40
  /**
40
41
  * Grace period a release waits for a provider iterator that ignores abort,
41
42
  * so a timed-out run's scope close never hangs. This is a Mule transport
42
43
  * detail with no Pi equivalent, so it stays a constant, not host config.
43
44
  */
44
45
  const providerIteratorReleaseGraceMs = 1_000;
46
+ const modelLimitCause = Symbol("Mule model limit");
45
47
  /** Apply Pi's maintained context-overflow semantics to an exact Pi message. */
46
48
  export function isContextOverflow(message, contextWindow) {
47
49
  return piIsContextOverflow(message, contextWindow);
@@ -235,17 +237,47 @@ function pullPiEvent(iterator, controller) {
235
237
  catch: (cause) => failure("model", "Pi model stream failed", cause),
236
238
  });
237
239
  }
238
- function withModelStreamDeadline(effect, timeoutMs) {
239
- if (timeoutMs === undefined)
240
- return effect;
240
+ function withModelStreamDeadline(effect, timeoutMs, reason) {
241
241
  return effect.pipe(Effect.timeoutOrElse({
242
242
  duration: timeoutMs,
243
- 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)),
244
244
  }));
245
245
  }
246
246
  function streamTurn(input, context, run, turn, emit, attempt) {
247
247
  const messageIdentity = identity("message", turn);
248
+ let partial;
249
+ const streamState = { started: false, terminal: undefined };
248
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
+ };
249
281
  const controller = new AbortController();
250
282
  yield* Effect.addFinalizer(() => Effect.sync(() => {
251
283
  controller.abort("Mule model scope closed");
@@ -273,17 +305,13 @@ function streamTurn(input, context, run, turn, emit, attempt) {
273
305
  const descriptors = (input.tools ?? []).map((tool) => tool.descriptor);
274
306
  if (descriptors.length > 0)
275
307
  piContext.tools = descriptors;
276
- const response = yield* withModelStreamDeadline(Effect.tryPromise({
308
+ const response = yield* deadline(Effect.tryPromise({
277
309
  try: async () => {
278
310
  const stream = input.stream ?? streamPi;
279
311
  return stream(input.model, normalizeContext(piContext), options);
280
312
  },
281
313
  catch: (cause) => failure("model", "Pi model stream could not start", cause),
282
- }), input.modelStreamIdleTimeoutMs);
283
- const streamState = {
284
- started: false,
285
- terminal: undefined,
286
- };
314
+ }));
287
315
  const iterator = yield* Effect.acquireRelease(Effect.sync(() => response[Symbol.asyncIterator]()), (active) => Effect.tryPromise({
288
316
  try: () => {
289
317
  controller.abort("Mule model stream closed");
@@ -303,6 +331,19 @@ function streamTurn(input, context, run, turn, emit, attempt) {
303
331
  catch: (cause) => failure("model", "Pi model stream failed", cause),
304
332
  }).pipe(Effect.ignore));
305
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;
306
347
  switch (event.type) {
307
348
  case "start": {
308
349
  context.push(event.partial);
@@ -345,17 +386,17 @@ function streamTurn(input, context, run, turn, emit, attempt) {
345
386
  }
346
387
  }
347
388
  };
348
- const pull = () => withModelStreamDeadline(pullPiEvent(iterator, controller), input.modelStreamIdleTimeoutMs);
389
+ const pull = () => deadline(pullPiEvent(iterator, controller));
349
390
  let next = yield* pull();
350
391
  while (!next.done) {
351
392
  yield* observeEvent(next.value);
352
393
  next = yield* pull();
353
394
  }
354
395
  const completed = streamState.terminal ??
355
- (yield* withModelStreamDeadline(Effect.tryPromise({
396
+ (yield* deadline(Effect.tryPromise({
356
397
  try: () => response.result(),
357
398
  catch: (cause) => failure("model", "Pi model stream did not settle", cause),
358
- }), input.modelStreamIdleTimeoutMs));
399
+ })));
359
400
  if (streamState.started)
360
401
  context[context.length - 1] = completed;
361
402
  else {
@@ -376,7 +417,62 @@ function streamTurn(input, context, run, turn, emit, attempt) {
376
417
  value: completed,
377
418
  });
378
419
  return { identity: messageIdentity, message: completed };
379
- }));
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
+ })));
380
476
  }
381
477
  /**
382
478
  * Retry only the host's typed transient decision, using Pi's maintained delay.
@@ -430,13 +526,14 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
430
526
  policy === undefined ||
431
527
  !policy.enabled ||
432
528
  retries >= policy.maxRetries) {
433
- if (retries > 0)
529
+ if (retries > 0) {
434
530
  yield* finish(message.stopReason !== "error" &&
435
531
  message.stopReason !== "aborted", message.stopReason === "error" ? message.errorMessage : undefined);
532
+ }
436
533
  return latest;
437
534
  }
438
535
  const retryable = yield* Effect.try({
439
- try: () => input.retryable(message),
536
+ try: () => latest?.limitReached === true || input.retryable(message),
440
537
  catch: (cause) => failure("model", "Typed model retry decision failed", cause),
441
538
  });
442
539
  if (!retryable) {
@@ -450,12 +547,16 @@ function streamTurnWithRetry(input, context, run, turn, emit) {
450
547
  try: () => input.retryAfterMs?.(message),
451
548
  catch: (cause) => failure("model", "Model retry delay observation failed", cause),
452
549
  });
453
- const delayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
550
+ const minimumDelayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
454
551
  Number.isSafeInteger(after) &&
455
552
  after >= 0 &&
456
553
  after <= 60_000
457
554
  ? after
458
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);
459
560
  yield* reportRetry({
460
561
  type: "retry_scheduled",
461
562
  run,
@@ -529,11 +630,14 @@ function completeLoopTurn(input, context, newMessages, run, turn, pending, emit)
529
630
  message: streamed.message,
530
631
  toolResults: batch.messages,
531
632
  });
532
- return {
633
+ const completed = {
533
634
  message: streamed.message,
534
635
  toolResults: batch.messages,
535
636
  terminate: batch.terminate,
536
637
  };
638
+ if (streamed.limitReached && streamed.message.errorMessage !== undefined)
639
+ return { ...completed, modelLimitReason: streamed.message.errorMessage };
640
+ return completed;
537
641
  });
538
642
  }
539
643
  /** Resolve only missing results. A stored and current safe policy are both required. */
@@ -630,10 +734,19 @@ function selectNextTurn(input, turn, context, newMessages) {
630
734
  return { continue: followUps.length > 0, pending: followUps };
631
735
  });
632
736
  }
633
- function finishRun(run, terminal, message, messages, status, emit) {
737
+ function finishRun(run, terminal, message, messages, status, emit, modelLimitReason) {
634
738
  return Effect.gen(function* () {
635
739
  yield* emit({ type: "run_end", run, terminal, outcome: status });
636
- return { status, run, terminal, message, messages };
740
+ const outcome = {
741
+ status,
742
+ run,
743
+ terminal,
744
+ message,
745
+ messages,
746
+ };
747
+ if (modelLimitReason !== undefined)
748
+ return { ...outcome, modelLimitReason };
749
+ return outcome;
637
750
  });
638
751
  }
639
752
  /** Run Pi's model/tool loop inside Effect without changing Pi's values. */
@@ -702,7 +815,7 @@ function runLoop(input, emit, runIdentity, terminalIdentity) {
702
815
  finalMessage = completed.message;
703
816
  const status = statusFor(completed.message);
704
817
  if (status !== "completed") {
705
- return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit);
818
+ return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit, completed.modelLimitReason);
706
819
  }
707
820
  // Tool results always continue the run: this is where Pi checks its
708
821
  // context before the next assistant response (Pi 0.84.4+).
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { decodeProviderData } from "./provider-data.js";
2
2
  export { streamPi as streamModel } from "./provider.js";
3
3
  export { normalizeModel } from "./provider-model.js";
4
4
  import type { Message, Tool, ToolCall } from "./types.js";
5
- export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
5
+ export { isRetryableProviderRejection, observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
6
6
  import type { MuleCompletedTurn, MuleEvent, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
7
7
  export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
8
8
  /** Promise listener adapted once into Mule's canonical Effect program. */
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { decodeProviderData } from "./provider-data.js";
2
2
  export { streamPi as streamModel } from "./provider.js";
3
3
  export { normalizeModel } from "./provider-model.js";
4
- export { observeProviderStatus, } from "./provider-status.js";
4
+ export { isRetryableProviderRejection, observeProviderStatus, } from "./provider-status.js";
5
5
  import * as Cause from "effect/Cause";
6
6
  import * as Effect from "effect/Effect";
7
7
  import * as Exit from "effect/Exit";
@@ -1,7 +1,15 @@
1
- import type { SimpleStreamOptions } from "./types.js";
1
+ import type { AssistantMessage, 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";
5
+ /** Provider-declared epoch milliseconds, absent when unknown. */
6
+ resetAt?: number;
7
+ limit?: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet";
8
+ }> | Readonly<{
9
+ code: "rate-limit";
10
+ retryAfterMs?: number;
11
+ }> | Readonly<{
12
+ code: "overloaded";
5
13
  }> | Readonly<{
6
14
  code: "provider_unavailable";
7
15
  status?: number;
@@ -9,6 +17,8 @@ export type ProviderRejection = Readonly<{
9
17
  code: "provider_rejected";
10
18
  status: number;
11
19
  }>;
20
+ /** Retry only typed transient failures, never usage exhaustion or rejection. */
21
+ export declare function isRetryableProviderRejection(rejection: ProviderRejection | undefined): boolean;
12
22
  /** One provider call's transport and parsed-event observations. */
13
23
  export type ProviderStatusRecorder = Readonly<{
14
24
  /** Request-local fetch observer, retaining the supplied transport. */
@@ -16,7 +26,7 @@ export type ProviderStatusRecorder = Readonly<{
16
26
  /** Pi's maintained parsed-event hook, composed with the supplied observer. */
17
27
  onProviderStreamEvent: NonNullable<SimpleStreamOptions["onProviderStreamEvent"]>;
18
28
  /** Latest typed failure evidence for this call, or no observed failure. */
19
- rejection: () => ProviderRejection | undefined;
29
+ rejection: (message?: AssistantMessage) => ProviderRejection | undefined;
20
30
  }>;
21
31
  /**
22
32
  * Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
@@ -1,8 +1,9 @@
1
- import { Option, Schema } from "effect";
1
+ import { DateTime, Option, Schema } from "effect";
2
2
  const usageLimitCode = "subscription_sharing_usage_limit_exceeded";
3
3
  const providerError = Schema.Struct({
4
4
  error: Schema.Struct({
5
- code: Schema.String,
5
+ code: Schema.optional(Schema.String),
6
+ type: Schema.optional(Schema.String),
6
7
  }),
7
8
  });
8
9
  const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
@@ -12,13 +13,70 @@ const decodeFailedEvent = Schema.decodeUnknownOption(Schema.Struct({
12
13
  const decodeErrorReply = Schema.decodeUnknownOption(Schema.fromJsonString(providerError));
13
14
  const decodeBodyChunk = Schema.decodeUnknownSync(Schema.instanceOf(Uint8Array));
14
15
  const errorBodyBytes = 16 * 1024;
16
+ const decodeSeconds = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isBetween({ minimum: 0, maximum: 60 })));
17
+ const decodeReset = Schema.decodeUnknownOption(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: 8_640_000_000_000 })));
18
+ const decodeLimit = Schema.decodeUnknownOption(Schema.Literals([
19
+ "five_hour",
20
+ "seven_day",
21
+ "seven_day_opus",
22
+ "seven_day_sonnet",
23
+ ]));
24
+ const decodeRejected = Schema.decodeUnknownOption(Schema.Literal("rejected"));
25
+ const decodeExhausted = Schema.decodeUnknownOption(Schema.Literal("0"));
26
+ const decodeDate = Schema.decodeUnknownOption(Schema.DateTimeUtcFromString);
27
+ const decodeOverloaded = Schema.decodeUnknownOption(Schema.Struct({
28
+ type: Schema.Literal("error"),
29
+ error: Schema.Struct({ type: Schema.Literal("overloaded_error") }),
30
+ }));
31
+ function resetDelay(value, now) {
32
+ const date = decodeDate(value);
33
+ if (Option.isNone(date))
34
+ return undefined;
35
+ const delay = DateTime.toEpochMillis(date.value) - now;
36
+ return delay >= 0 && delay <= 60_000 ? Math.ceil(delay) : undefined;
37
+ }
38
+ function rateLimit(headers) {
39
+ const now = Date.now();
40
+ const header = headers.get("retry-after");
41
+ const seconds = decodeSeconds(header);
42
+ let delay = Option.isSome(seconds)
43
+ ? Math.ceil(seconds.value * 1000)
44
+ : resetDelay(header, now);
45
+ // API-key rate windows are not subscription caps. Only an explicitly
46
+ // exhausted dimension supplies a retry floor; missing facts stay unknown.
47
+ for (const dimension of [
48
+ "requests",
49
+ "tokens",
50
+ "input-tokens",
51
+ "output-tokens",
52
+ ]) {
53
+ if (Option.isNone(decodeExhausted(headers.get(`anthropic-ratelimit-${dimension}-remaining`))))
54
+ continue;
55
+ const reset = resetDelay(headers.get(`anthropic-ratelimit-${dimension}-reset`), now);
56
+ if (reset !== undefined)
57
+ delay = Math.max(delay ?? 0, reset);
58
+ }
59
+ return delay !== undefined
60
+ ? { code: "rate-limit", retryAfterMs: delay }
61
+ : { code: "rate-limit" };
62
+ }
63
+ function usageLimit(headers) {
64
+ const reset = decodeReset(headers.get("anthropic-ratelimit-unified-reset"));
65
+ const limit = decodeLimit(headers.get("anthropic-ratelimit-unified-representative-claim"));
66
+ let result = { code: "usage-limit-reached" };
67
+ if (Option.isSome(reset))
68
+ result = { ...result, resetAt: reset.value * 1000 };
69
+ if (Option.isSome(limit))
70
+ result = { ...result, limit: limit.value };
71
+ return result;
72
+ }
15
73
  /** Pi's pinned provider-request transient classes, kept as typed evidence. */
16
74
  const transientStatus = (status) => status === 408 ||
17
75
  status === 409 ||
18
76
  status === 429 ||
19
77
  (status >= 500 && status <= 599);
20
78
  /** Retain Local's bounded error-copy observation; Pi still owns its response. */
21
- async function providerErrorCode(response) {
79
+ async function providerErrorKind(response) {
22
80
  const body = response.clone().body;
23
81
  if (body === null)
24
82
  return undefined;
@@ -39,7 +97,9 @@ async function providerErrorCode(response) {
39
97
  }
40
98
  text += decoder.decode();
41
99
  const decoded = decodeErrorReply(text);
42
- return Option.isSome(decoded) ? decoded.value.error.code : undefined;
100
+ return Option.isSome(decoded)
101
+ ? (decoded.value.error.code ?? decoded.value.error.type)
102
+ : undefined;
43
103
  }
44
104
  finally {
45
105
  // A tee's cancellation may await the original consumer. Do not hold Pi's
@@ -50,6 +110,20 @@ async function providerErrorCode(response) {
50
110
  reader.releaseLock();
51
111
  }
52
112
  }
113
+ /** Retry only typed transient failures, never usage exhaustion or rejection. */
114
+ export function isRetryableProviderRejection(rejection) {
115
+ if (rejection === undefined)
116
+ return false;
117
+ switch (rejection.code) {
118
+ case "provider_unavailable":
119
+ case "rate-limit":
120
+ case "overloaded":
121
+ return true;
122
+ case "usage-limit-reached":
123
+ case "provider_rejected":
124
+ return false;
125
+ }
126
+ }
53
127
  /**
54
128
  * Observe one call's typed HTTP and parsed-event evidence. Pi owns stream
55
129
  * decoding; this module retains no provider prose.
@@ -69,15 +143,25 @@ export function observeProviderStatus(options) {
69
143
  throw cause;
70
144
  }
71
145
  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 };
146
+ const code = status === 429 || status === 529
147
+ ? await providerErrorKind(response)
148
+ : undefined;
149
+ const subscriptionRejected = status === 429 &&
150
+ Option.isSome(decodeRejected(response.headers.get("anthropic-ratelimit-unified-status")));
151
+ if (status < 400 || status > 599)
152
+ rejection = undefined;
153
+ else if (subscriptionRejected)
154
+ rejection = usageLimit(response.headers);
155
+ else if (code === usageLimitCode)
156
+ rejection = { code: "usage-limit-reached" };
157
+ else if (code === "overloaded_error")
158
+ rejection = { code: "overloaded" };
159
+ else if (status === 429 && code !== "insufficient_quota")
160
+ rejection = rateLimit(response.headers);
161
+ else if (transientStatus(status) && code !== "insufficient_quota")
162
+ rejection = { code: "provider_unavailable", status };
163
+ else
164
+ rejection = { code: "provider_rejected", status };
81
165
  return response;
82
166
  };
83
167
  return {
@@ -87,8 +171,27 @@ export function observeProviderStatus(options) {
87
171
  if (Option.isSome(failed) &&
88
172
  failed.value.response.error.code === usageLimitCode)
89
173
  rejection = { code: "usage-limit-reached" };
174
+ if (Option.isSome(decodeOverloaded(data)))
175
+ rejection = { code: "overloaded" };
90
176
  await options?.onProviderStreamEvent?.(data, model);
91
177
  },
92
- rejection: () => rejection,
178
+ rejection: (message) => {
179
+ if (rejection !== undefined)
180
+ return rejection;
181
+ // Pi throws Anthropic event:error before its parsed-event hook and
182
+ // retains that JSON envelope verbatim as the terminal errorMessage.
183
+ // Decode that maintained producer's representation, never match prose.
184
+ const text = message?.errorMessage;
185
+ if (message?.stopReason !== "error" ||
186
+ text === undefined ||
187
+ text.length > errorBodyBytes ||
188
+ new TextEncoder().encode(text).byteLength > errorBodyBytes)
189
+ return undefined;
190
+ const decoded = decodeErrorReply(text);
191
+ return Option.isSome(decoded) &&
192
+ decoded.value.error.type === "overloaded_error"
193
+ ? { code: "overloaded" }
194
+ : undefined;
195
+ },
93
196
  };
94
197
  }
package/dist/types.d.ts CHANGED
@@ -177,6 +177,8 @@ export type AssistantMessage = {
177
177
  stopReason: StopReason;
178
178
  deferred?: DeferredHandle;
179
179
  errorMessage?: string;
180
+ /** Provider prose for the failed conversation turn, never a diagnostic. */
181
+ providerError?: string;
180
182
  rawStopReason?: string;
181
183
  endTurn?: boolean;
182
184
  timestamp: number;
@@ -1091,6 +1091,14 @@ optional deferred?: DeferredHandle;
1091
1091
  optional errorMessage?: string;
1092
1092
  ```
1093
1093
 
1094
+ ##### providerError?
1095
+
1096
+ ```ts
1097
+ optional providerError?: string;
1098
+ ```
1099
+
1100
+ Provider prose for the failed conversation turn, never a diagnostic.
1101
+
1094
1102
  ##### rawStopReason?
1095
1103
 
1096
1104
  ```ts
@@ -1847,18 +1855,6 @@ that wants Pi coding-agent's numbers passes this explicitly.
1847
1855
 
1848
1856
  ---
1849
1857
 
1850
- ### piHttpIdleTimeoutMs
1851
-
1852
- ```ts
1853
- const piHttpIdleTimeoutMs: 300000 = 300_000;
1854
- ```
1855
-
1856
- Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
1857
- `settings-manager.js`). A host that wants Pi's number for
1858
- `modelStreamIdleTimeoutMs` passes this explicitly.
1859
-
1860
- ---
1861
-
1862
1858
  ### muleEffect
1863
1859
 
1864
1860
  ```ts
@@ -1924,6 +1920,12 @@ Re-exports [streamModel](index.md#streammodel)
1924
1920
 
1925
1921
  ---
1926
1922
 
1923
+ ### isRetryableProviderRejection
1924
+
1925
+ Re-exports [isRetryableProviderRejection](index.md#isretryableproviderrejection)
1926
+
1927
+ ---
1928
+
1927
1929
  ### observeProviderStatus
1928
1930
 
1929
1931
  Re-exports [observeProviderStatus](index.md#observeproviderstatus)
package/docs/api/index.md CHANGED
@@ -657,6 +657,18 @@ readonly optional modelStreamIdleTimeoutMs?: number;
657
657
 
658
658
  Maximum silence between provider stream events, in milliseconds.
659
659
 
660
+ ##### modelStreamLimits?
661
+
662
+ ```ts
663
+ readonly optional modelStreamLimits?: Partial<{
664
+ firstEventMs: number;
665
+ attemptMs: number;
666
+ outputBytes: number;
667
+ }>;
668
+ ```
669
+
670
+ Trusted per-attempt safety limits may only tighten Mule's defaults.
671
+
660
672
  ##### toolExecution?
661
673
 
662
674
  ```ts
@@ -754,6 +766,14 @@ readonly message: AssistantMessage;
754
766
  readonly messages: readonly Message[];
755
767
  ```
756
768
 
769
+ ##### modelLimitReason?
770
+
771
+ ```ts
772
+ readonly optional modelLimitReason?: string;
773
+ ```
774
+
775
+ Mule-owned limit diagnostic, not untrusted provider prose.
776
+
757
777
  ---
758
778
 
759
779
  ### MulePromiseListener
@@ -923,6 +943,15 @@ the run, or with the abort reason (an `AbortError` when the reason is not an
923
943
  type ProviderRejection =
924
944
  | Readonly<{
925
945
  code: "usage-limit-reached";
946
+ resetAt?: number;
947
+ limit?: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet";
948
+ }>
949
+ | Readonly<{
950
+ code: "rate-limit";
951
+ retryAfterMs?: number;
952
+ }>
953
+ | Readonly<{
954
+ code: "overloaded";
926
955
  }>
927
956
  | Readonly<{
928
957
  code: "provider_unavailable";
@@ -946,7 +975,7 @@ type ProviderStatusRecorder = Readonly<{
946
975
  onProviderStreamEvent: NonNullable<
947
976
  SimpleStreamOptions["onProviderStreamEvent"]
948
977
  >;
949
- rejection: () => ProviderRejection | undefined;
978
+ rejection: (message?) => ProviderRejection | undefined;
950
979
  }>;
951
980
  ```
952
981
 
@@ -1020,6 +1049,26 @@ Validate a catalog model's opaque sampling fields before it enters Mule.
1020
1049
 
1021
1050
  ---
1022
1051
 
1052
+ ### isRetryableProviderRejection()
1053
+
1054
+ ```ts
1055
+ function isRetryableProviderRejection(rejection): boolean;
1056
+ ```
1057
+
1058
+ Retry only typed transient failures, never usage exhaustion or rejection.
1059
+
1060
+ #### Parameters
1061
+
1062
+ | Parameter | Type |
1063
+ | ----------- | -------------------------------------------------------- |
1064
+ | `rejection` | [`ProviderRejection`](#providerrejection) \| `undefined` |
1065
+
1066
+ #### Returns
1067
+
1068
+ `boolean`
1069
+
1070
+ ---
1071
+
1023
1072
  ### observeProviderStatus()
1024
1073
 
1025
1074
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/mule",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Effect-native Pi model and tool loop",
6
6
  "type": "module",