@fungi.computer/mule 0.1.1 → 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.
@@ -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
@@ -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
@@ -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) {
@@ -529,11 +626,14 @@ function completeLoopTurn(input, context, newMessages, run, turn, pending, emit)
529
626
  message: streamed.message,
530
627
  toolResults: batch.messages,
531
628
  });
532
- return {
629
+ const completed = {
533
630
  message: streamed.message,
534
631
  toolResults: batch.messages,
535
632
  terminate: batch.terminate,
536
633
  };
634
+ if (streamed.limitReached && streamed.message.errorMessage !== undefined)
635
+ return { ...completed, modelLimitReason: streamed.message.errorMessage };
636
+ return completed;
537
637
  });
538
638
  }
539
639
  /** Resolve only missing results. A stored and current safe policy are both required. */
@@ -630,10 +730,19 @@ function selectNextTurn(input, turn, context, newMessages) {
630
730
  return { continue: followUps.length > 0, pending: followUps };
631
731
  });
632
732
  }
633
- function finishRun(run, terminal, message, messages, status, emit) {
733
+ function finishRun(run, terminal, message, messages, status, emit, modelLimitReason) {
634
734
  return Effect.gen(function* () {
635
735
  yield* emit({ type: "run_end", run, terminal, outcome: status });
636
- 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;
637
746
  });
638
747
  }
639
748
  /** Run Pi's model/tool loop inside Effect without changing Pi's values. */
@@ -702,7 +811,7 @@ function runLoop(input, emit, runIdentity, terminalIdentity) {
702
811
  finalMessage = completed.message;
703
812
  const status = statusFor(completed.message);
704
813
  if (status !== "completed") {
705
- return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit);
814
+ return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit, completed.modelLimitReason);
706
815
  }
707
816
  // Tool results always continue the run: this is where Pi checks its
708
817
  // context before the next assistant response (Pi 0.84.4+).
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;
@@ -1847,18 +1847,6 @@ that wants Pi coding-agent's numbers passes this explicitly.
1847
1847
 
1848
1848
  ---
1849
1849
 
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
1850
  ### muleEffect
1863
1851
 
1864
1852
  ```ts
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
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.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Effect-native Pi model and tool loop",
6
6
  "type": "module",