@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.
- package/dist/contract.d.ts +8 -0
- package/dist/effect.d.ts +0 -6
- package/dist/effect.js +135 -26
- package/dist/types.d.ts +2 -0
- package/docs/api/effect.md +0 -12
- package/docs/api/index.md +20 -0
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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",
|
|
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*
|
|
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
|
-
})
|
|
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 = () =>
|
|
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*
|
|
396
|
+
(yield* deadline(Effect.tryPromise({
|
|
356
397
|
try: () => response.result(),
|
|
357
398
|
catch: (cause) => failure("model", "Pi model stream did not settle", cause),
|
|
358
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/docs/api/effect.md
CHANGED
|
@@ -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
|