@fungi.computer/mule 0.0.2 → 0.1.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.
- package/README.md +1 -0
- package/THIRD_PARTY_NOTICES +8 -3
- package/dist/contract.d.ts +17 -6
- package/dist/effect.d.ts +6 -2
- package/dist/effect.js +87 -125
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/pi-options.d.ts +6 -0
- package/dist/pi-options.js +35 -0
- package/dist/provider-data.d.ts +3 -0
- package/dist/provider-data.js +17 -0
- package/dist/provider-model.d.ts +3 -0
- package/dist/provider-model.js +18 -0
- package/dist/provider-status.d.ts +25 -0
- package/dist/provider-status.js +94 -0
- package/dist/provider.d.ts +3 -0
- package/dist/provider.js +7 -0
- package/dist/testing.d.ts +49 -4
- package/dist/testing.js +16 -7
- package/dist/types.d.ts +381 -0
- package/dist/types.js +1 -0
- package/docs/api/effect.md +1693 -18
- package/docs/api/index.md +184 -33
- package/docs/api/testing.md +233 -6
- 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/THIRD_PARTY_NOTICES
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
Mule's direct runtime dependencies are Effect
|
|
2
|
-
@earendil-works/pi-ai
|
|
1
|
+
Mule's direct runtime dependencies are Effect 4.0.2, Cairn, and
|
|
2
|
+
@earendil-works/pi-ai 1.3.0. Consult each dependency's distribution for its
|
|
3
3
|
license terms.
|
|
4
4
|
|
|
5
|
-
The Pi
|
|
5
|
+
The original Pi loop source was pinned from commit
|
|
6
6
|
853a80d26c90a14c1886f0ebb8ffaae133ca2185 and distributed under the MIT
|
|
7
7
|
License, Copyright (c) 2025 Mario Zechner. Its independently archived source
|
|
8
8
|
boundary has SHA-256
|
|
@@ -13,3 +13,8 @@ dfb11f4c1c00e47003db12765371198e944c8b640c995a1dc1cf9130bc9e49ee.
|
|
|
13
13
|
behavior from Botanical's retained @shiit/agent-core 0.66.2 at commit
|
|
14
14
|
b6eabe5dfd3cac9514025d26abbe9a92b083f10b, distributed under the MIT License,
|
|
15
15
|
Copyright (c) 2024 fungi.computer.
|
|
16
|
+
|
|
17
|
+
`src/types.ts` adapts the provider-neutral data declarations from
|
|
18
|
+
@earendil-works/pi-ai 1.3.0, distributed under the MIT License,
|
|
19
|
+
Copyright (c) 2025 Mario Zechner. Mule owns this public structural contract;
|
|
20
|
+
provider implementations remain in Pi.
|
package/dist/contract.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream,
|
|
1
|
+
import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, ImageContent, JsonValue, Message, Model, RetryPolicy, SimpleStreamOptions, TextContent, ToolCall, ToolResultMessage, Usage } from "./types.js";
|
|
2
2
|
/** Pi's model stream function, supplied explicitly by the caller. */
|
|
3
|
-
export type MuleStreamFunction = (model: Model<Api>, context:
|
|
3
|
+
export type MuleStreamFunction = (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
|
|
4
4
|
/** Causal roles added around Pi's model and tool loop. */
|
|
5
5
|
export type MuleIdentityKind = "run" | "turn" | "message" | "tool_call" | "tool_result" | "terminal";
|
|
6
6
|
/** One run-local causal identity. */
|
|
@@ -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,8 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
export { decodeProviderData } from "./provider-data.js";
|
|
2
|
+
export { streamPi as streamModel } from "./provider.js";
|
|
3
|
+
export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
|
|
4
|
+
export { normalizeModel } from "./provider-model.js";
|
|
5
|
+
import type { AssistantMessage, Message, RetryPolicy, Tool, ToolCall } from "./types.js";
|
|
2
6
|
import * as Effect from "effect/Effect";
|
|
3
7
|
import * as Stream from "effect/Stream";
|
|
4
8
|
import type { MuleCompletedTurn, MuleError, MuleEvent, MuleRetryObservation, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
|
|
5
|
-
export type
|
|
9
|
+
export type * from "./types.js";
|
|
6
10
|
export { Type } from "typebox";
|
|
7
11
|
export type { MuleRetryObservation } from "./contract.js";
|
|
8
12
|
/**
|
package/dist/effect.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
|
|
2
|
+
export { decodeProviderData } from "./provider-data.js";
|
|
3
|
+
export { streamPi as streamModel } from "./provider.js";
|
|
1
4
|
import { nanoid } from "@fungi.computer/cairn";
|
|
5
|
+
export { observeProviderStatus, } from "./provider-status.js";
|
|
2
6
|
// Pi's root entry also evaluates its TypeBox validator compiler and model
|
|
3
7
|
// stores; Mule needs only these leaf helpers, so it imports them directly and
|
|
4
8
|
// keeps that work out of every host's startup.
|
|
5
9
|
import { isContextOverflow as piIsContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
10
|
+
import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, retryDelayMs, } from "@earendil-works/pi-ai/utils/retry";
|
|
11
|
+
import { streamPi } from "./provider.js";
|
|
12
|
+
export { normalizeModel } from "./provider-model.js";
|
|
8
13
|
import * as Effect from "effect/Effect";
|
|
9
|
-
import * as Schema from "effect/Schema";
|
|
10
14
|
import * as Cause from "effect/Cause";
|
|
11
15
|
import * as Result from "effect/Result";
|
|
12
16
|
import * as Exit from "effect/Exit";
|
|
13
|
-
import * as Fiber from "effect/Fiber";
|
|
14
17
|
import * as Option from "effect/Option";
|
|
15
18
|
import * as Semaphore from "effect/Semaphore";
|
|
16
19
|
import * as Stream from "effect/Stream";
|
|
@@ -44,13 +47,6 @@ export function isContextOverflow(message, contextWindow) {
|
|
|
44
47
|
return piIsContextOverflow(message, contextWindow);
|
|
45
48
|
}
|
|
46
49
|
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
50
|
const identity = (kind, parent) => ({ kind, value: `mule_${kind}_${nanoid()}`, parent });
|
|
55
51
|
const noListener = () => Effect.void;
|
|
56
52
|
function resultMessage(call, result) {
|
|
@@ -62,8 +58,6 @@ function resultMessage(call, result) {
|
|
|
62
58
|
isError: result.isError,
|
|
63
59
|
timestamp: Date.now(),
|
|
64
60
|
};
|
|
65
|
-
if (result.details !== undefined)
|
|
66
|
-
message.details = result.details;
|
|
67
61
|
if (result.usage !== undefined)
|
|
68
62
|
message.usage = result.usage;
|
|
69
63
|
return message;
|
|
@@ -174,6 +168,7 @@ function emitToolResult(executed, run, turn, emit) {
|
|
|
174
168
|
turn,
|
|
175
169
|
message: messageIdentity,
|
|
176
170
|
value: message,
|
|
171
|
+
data: executed.result.data,
|
|
177
172
|
});
|
|
178
173
|
return message;
|
|
179
174
|
});
|
|
@@ -261,6 +256,7 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
261
256
|
input.streamOptions?.apiKey;
|
|
262
257
|
const options = {
|
|
263
258
|
...input.streamOptions,
|
|
259
|
+
maxRetries: 0,
|
|
264
260
|
signal: controller.signal,
|
|
265
261
|
};
|
|
266
262
|
if (resolvedApiKey !== undefined)
|
|
@@ -279,8 +275,7 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
279
275
|
piContext.tools = descriptors;
|
|
280
276
|
const response = yield* withModelStreamDeadline(Effect.tryPromise({
|
|
281
277
|
try: async () => {
|
|
282
|
-
const stream = input.stream ??
|
|
283
|
-
(await import("@earendil-works/pi-ai/compat")).streamSimple;
|
|
278
|
+
const stream = input.stream ?? streamPi;
|
|
284
279
|
return stream(input.model, normalizeContext(piContext), options);
|
|
285
280
|
},
|
|
286
281
|
catch: (cause) => failure("model", "Pi model stream could not start", cause),
|
|
@@ -384,9 +379,8 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
384
379
|
}));
|
|
385
380
|
}
|
|
386
381
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
* calls `streamTurn` exactly once and returns its result unchanged.
|
|
382
|
+
* Retry only the host's typed transient decision, using Pi's maintained delay.
|
|
383
|
+
* Effect owns interruption of both the active stream and the backoff sleep.
|
|
390
384
|
*
|
|
391
385
|
* Each attempt streams and is recorded exactly like any other turn (so a
|
|
392
386
|
* failed attempt stays in the retained event log for inspection), but a
|
|
@@ -395,123 +389,91 @@ function streamTurn(input, context, run, turn, emit, attempt) {
|
|
|
395
389
|
*/
|
|
396
390
|
function streamTurnWithRetry(input, context, run, turn, emit) {
|
|
397
391
|
return Effect.gen(function* () {
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
392
|
+
// One turn owns one finite budget, even if its caller updates future policy.
|
|
393
|
+
const policy = input.retry === undefined ? undefined : { ...input.retry };
|
|
394
|
+
if (policy?.enabled &&
|
|
395
|
+
[
|
|
396
|
+
policy.maxRetries,
|
|
397
|
+
policy.baseDelayMs,
|
|
398
|
+
policy.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS,
|
|
399
|
+
].some((value) => !Number.isSafeInteger(value) || value < 0))
|
|
400
|
+
return yield* Effect.fail(failure("model", "Retry policy requires non-negative safe-integer counts and delays"));
|
|
401
401
|
let latest;
|
|
402
|
-
let
|
|
403
|
-
let
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
402
|
+
let calls = 0;
|
|
403
|
+
let retries = 0;
|
|
404
|
+
let finished = false;
|
|
405
|
+
let lastError;
|
|
406
|
+
const reportRetry = (event) => input.onRetry === undefined ? Effect.void : input.onRetry(event);
|
|
407
|
+
const finish = (success, finalError) => Effect.gen(function* () {
|
|
408
|
+
finished = true;
|
|
409
|
+
yield* reportRetry(finalError === undefined
|
|
410
|
+
? { type: "retry_finished", run, turn, attempt: retries, success }
|
|
411
|
+
: {
|
|
412
|
+
type: "retry_finished",
|
|
413
|
+
run,
|
|
414
|
+
turn,
|
|
415
|
+
attempt: retries,
|
|
416
|
+
success,
|
|
417
|
+
finalError,
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
return yield* Effect.gen(function* () {
|
|
421
|
+
for (;;) {
|
|
422
|
+
if (latest !== undefined) {
|
|
423
|
+
const index = context.indexOf(latest.message);
|
|
424
|
+
if (index !== -1)
|
|
425
|
+
context.splice(index, 1);
|
|
426
|
+
}
|
|
427
|
+
latest = yield* streamTurn(input, context, run, turn, emit, ++calls);
|
|
428
|
+
const message = latest.message;
|
|
429
|
+
if (message.stopReason !== "error" ||
|
|
430
|
+
policy === undefined ||
|
|
431
|
+
!policy.enabled ||
|
|
432
|
+
retries >= policy.maxRetries) {
|
|
433
|
+
if (retries > 0)
|
|
434
|
+
yield* finish(message.stopReason !== "error" &&
|
|
435
|
+
message.stopReason !== "aborted", message.stopReason === "error" ? message.errorMessage : undefined);
|
|
436
|
+
return latest;
|
|
437
|
+
}
|
|
438
|
+
const retryable = yield* Effect.try({
|
|
439
|
+
try: () => input.retryable(message),
|
|
440
|
+
catch: (cause) => failure("model", "Typed model retry decision failed", cause),
|
|
441
|
+
});
|
|
442
|
+
if (!retryable) {
|
|
443
|
+
if (retries > 0)
|
|
444
|
+
yield* finish(false, message.errorMessage);
|
|
445
|
+
return latest;
|
|
446
|
+
}
|
|
447
|
+
retries++;
|
|
448
|
+
lastError = message.errorMessage ?? "Unknown error";
|
|
449
|
+
const after = yield* Effect.try({
|
|
450
|
+
try: () => input.retryAfterMs?.(message),
|
|
451
|
+
catch: (cause) => failure("model", "Model retry delay observation failed", cause),
|
|
452
|
+
});
|
|
453
|
+
const delayMs = Math.max(retryDelayMs(policy, retries), after !== undefined &&
|
|
416
454
|
Number.isSafeInteger(after) &&
|
|
417
455
|
after >= 0 &&
|
|
418
456
|
after <= 60_000
|
|
419
|
-
?
|
|
420
|
-
: 0;
|
|
421
|
-
|
|
457
|
+
? after
|
|
458
|
+
: 0);
|
|
459
|
+
yield* reportRetry({
|
|
422
460
|
type: "retry_scheduled",
|
|
423
461
|
run,
|
|
424
462
|
turn,
|
|
425
|
-
attempt,
|
|
426
|
-
maxAttempts,
|
|
427
|
-
delayMs
|
|
428
|
-
errorMessage,
|
|
463
|
+
attempt: retries,
|
|
464
|
+
maxAttempts: policy.maxRetries,
|
|
465
|
+
delayMs,
|
|
466
|
+
errorMessage: lastError,
|
|
429
467
|
});
|
|
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",
|
|
468
|
+
yield* Effect.sleep(delayMs);
|
|
469
|
+
yield* reportRetry({
|
|
470
|
+
type: "retry_attempt_started",
|
|
450
471
|
run,
|
|
451
472
|
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 };
|
|
473
|
+
attempt: retries,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}).pipe(Effect.onInterrupt(() => retries === 0 || finished ? Effect.void : finish(false, lastError)));
|
|
515
477
|
});
|
|
516
478
|
}
|
|
517
479
|
const statusFor = (message) => message.stopReason === "aborted"
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
export { decodeProviderData } from "./provider-data.js";
|
|
2
|
+
export { streamPi as streamModel } from "./provider.js";
|
|
3
|
+
export { normalizeModel } from "./provider-model.js";
|
|
4
|
+
import type { Message, Tool, ToolCall } from "./types.js";
|
|
5
|
+
export { observeProviderStatus, type ProviderRejection, type ProviderStatusRecorder, } from "./provider-status.js";
|
|
2
6
|
import type { MuleCompletedTurn, MuleEvent, MuleRunOutcome, MuleRunSettings, MuleToolExecutionPart, MuleToolRejection } from "./contract.js";
|
|
3
7
|
export type { MuleCompletedTurn, MuleResume, MuleToolCheckpoint, MuleError, MuleEvent, MuleIdentity, MuleIdentityKind, MuleRunOutcome, MuleRunSettings, MuleStreamFunction, MuleToolExecutionPart, MuleToolRejection, MuleToolResult, } from "./contract.js";
|
|
4
8
|
/** Promise listener adapted once into Mule's canonical Effect program. */
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export { decodeProviderData } from "./provider-data.js";
|
|
2
|
+
export { streamPi as streamModel } from "./provider.js";
|
|
3
|
+
export { normalizeModel } from "./provider-model.js";
|
|
4
|
+
export { observeProviderStatus, } from "./provider-status.js";
|
|
1
5
|
import * as Cause from "effect/Cause";
|
|
2
6
|
import * as Effect from "effect/Effect";
|
|
3
7
|
import * as Exit from "effect/Exit";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SimpleStreamOptions as PiOptions } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { SimpleStreamOptions } from "./types.js";
|
|
3
|
+
/** Adapt Pi's parsed payload and event boundary before invoking owned hooks. */
|
|
4
|
+
export declare function piOptions(options: SimpleStreamOptions | undefined): PiOptions | undefined;
|
|
5
|
+
/** Restore the owned options shape when Pi calls a scripted provider factory. */
|
|
6
|
+
export declare function muleOptions(options: PiOptions | undefined): SimpleStreamOptions | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { decodeProviderData } from "./provider-data.js";
|
|
3
|
+
import { normalizeModel } from "./provider-model.js";
|
|
4
|
+
/** Adapt Pi's parsed payload and event boundary before invoking owned hooks. */
|
|
5
|
+
export function piOptions(options) {
|
|
6
|
+
if (options === undefined)
|
|
7
|
+
return undefined;
|
|
8
|
+
const { onPayload, onProviderStreamEvent, onResponse, ...rest } = options;
|
|
9
|
+
const adapted = { ...rest };
|
|
10
|
+
if (onPayload !== undefined)
|
|
11
|
+
adapted.onPayload = (payload, model) => onPayload(decodeProviderData(payload), normalizeModel(model));
|
|
12
|
+
if (onProviderStreamEvent !== undefined)
|
|
13
|
+
adapted.onProviderStreamEvent = (event, model) => onProviderStreamEvent(decodeProviderData(event), normalizeModel(model));
|
|
14
|
+
if (onResponse !== undefined)
|
|
15
|
+
adapted.onResponse = (response, model) => onResponse(response, normalizeModel(model));
|
|
16
|
+
return adapted;
|
|
17
|
+
}
|
|
18
|
+
/** Restore the owned options shape when Pi calls a scripted provider factory. */
|
|
19
|
+
export function muleOptions(options) {
|
|
20
|
+
if (options === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
const { samplingParams, metadata, onPayload, ...rest } = options;
|
|
23
|
+
const adapted = { ...rest };
|
|
24
|
+
const decodeObject = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json));
|
|
25
|
+
if (samplingParams !== undefined)
|
|
26
|
+
adapted.samplingParams = decodeObject(samplingParams);
|
|
27
|
+
if (metadata !== undefined)
|
|
28
|
+
adapted.metadata = decodeObject(metadata);
|
|
29
|
+
if (onPayload !== undefined)
|
|
30
|
+
adapted.onPayload = async (payload, model) => {
|
|
31
|
+
const result = await onPayload(payload, model);
|
|
32
|
+
return result === undefined ? undefined : decodeProviderData(result);
|
|
33
|
+
};
|
|
34
|
+
return adapted;
|
|
35
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
const providerValue = Schema.suspend(() => Schema.Union([
|
|
3
|
+
Schema.Undefined,
|
|
4
|
+
Schema.Null,
|
|
5
|
+
Schema.Boolean,
|
|
6
|
+
Schema.Number,
|
|
7
|
+
Schema.String,
|
|
8
|
+
Schema.Uint8Array,
|
|
9
|
+
Schema.instanceOf(AbortSignal),
|
|
10
|
+
Schema.Array(providerValue),
|
|
11
|
+
providerData,
|
|
12
|
+
]));
|
|
13
|
+
const providerData = Schema.Record(Schema.String, providerValue);
|
|
14
|
+
/** Decode Pi's parsed request/event data before it enters an owned hook. */
|
|
15
|
+
export function decodeProviderData(data) {
|
|
16
|
+
return Schema.decodeUnknownSync(providerData)(data);
|
|
17
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Api, Model } from "./types.js";
|
|
2
|
+
/** Validate a catalog model's opaque sampling fields before it enters Mule. */
|
|
3
|
+
export declare function normalizeModel<TApi extends Api>(model: Omit<Model<TApi>, "samplingParams" | "samplingParamsByThinkingLevel">): Model<TApi>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
const sampling = Schema.Record(Schema.String, Schema.Json);
|
|
3
|
+
const decodeSampling = Schema.decodeUnknownSync(Schema.Struct({
|
|
4
|
+
samplingParams: Schema.optionalKey(sampling),
|
|
5
|
+
samplingParamsByThinkingLevel: Schema.optionalKey(Schema.Struct({
|
|
6
|
+
off: Schema.optionalKey(sampling),
|
|
7
|
+
minimal: Schema.optionalKey(sampling),
|
|
8
|
+
low: Schema.optionalKey(sampling),
|
|
9
|
+
medium: Schema.optionalKey(sampling),
|
|
10
|
+
high: Schema.optionalKey(sampling),
|
|
11
|
+
xhigh: Schema.optionalKey(sampling),
|
|
12
|
+
max: Schema.optionalKey(sampling),
|
|
13
|
+
})),
|
|
14
|
+
}));
|
|
15
|
+
/** Validate a catalog model's opaque sampling fields before it enters Mule. */
|
|
16
|
+
export function normalizeModel(model) {
|
|
17
|
+
return { ...model, ...decodeSampling(model) };
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SimpleStreamOptions } from "./types.js";
|
|
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/provider.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript";
|
|
2
|
+
import { piOptions } from "./pi-options.js";
|
|
3
|
+
/** Pi's maintained stream behind Mule's structural provider port. */
|
|
4
|
+
export const streamPi = async (model, context, options) => {
|
|
5
|
+
const { streamSimple } = await import("@earendil-works/pi-ai/compat");
|
|
6
|
+
return streamSimple(model, normalizeContext(context), piOptions(options));
|
|
7
|
+
};
|