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