@fungi.computer/mule 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +120 -2
- package/THIRD_PARTY_NOTICES +15 -0
- package/dist/contract.d.ts +192 -0
- package/dist/contract.js +1 -0
- package/dist/effect.d.ts +73 -0
- package/dist/effect.js +762 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +97 -0
- package/dist/testing.d.ts +15 -0
- package/dist/testing.js +14 -0
- package/docs/api/README.md +15 -0
- package/docs/api/effect.md +350 -0
- package/docs/api/index.md +889 -0
- package/docs/api/testing.md +45 -0
- package/package.json +69 -3
package/dist/effect.js
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import { nanoid } from "@fungi.computer/cairn";
|
|
2
|
+
import { isContextOverflow as piIsContextOverflow, normalizeContext, retryAssistantCall, } from "@earendil-works/pi-ai";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as Cause from "effect/Cause";
|
|
6
|
+
import * as Result from "effect/Result";
|
|
7
|
+
import * as Exit from "effect/Exit";
|
|
8
|
+
import * as Fiber from "effect/Fiber";
|
|
9
|
+
import * as Option from "effect/Option";
|
|
10
|
+
import * as Semaphore from "effect/Semaphore";
|
|
11
|
+
import * as Stream from "effect/Stream";
|
|
12
|
+
export { Type } from "@earendil-works/pi-ai";
|
|
13
|
+
/**
|
|
14
|
+
* Pi coding-agent's retry defaults (`settings-manager.js` `getRetrySettings()`,
|
|
15
|
+
* `@earendil-works/pi-ai` 0.99.0, the version pinned in the lockfile). Pi-ai's
|
|
16
|
+
* own `RetryPolicy` has no default — an undefined policy means no retry — so
|
|
17
|
+
* a host that wants Pi coding-agent's numbers passes this explicitly.
|
|
18
|
+
*/
|
|
19
|
+
export const piCodingAgentRetryDefaults = {
|
|
20
|
+
enabled: true,
|
|
21
|
+
maxRetries: 3,
|
|
22
|
+
baseDelayMs: 2000,
|
|
23
|
+
maxAgentDelayMs: 60_000,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Pi coding-agent's default stream idle timeout (`DEFAULT_HTTP_IDLE_TIMEOUT_MS`,
|
|
27
|
+
* `settings-manager.js`). A host that wants Pi's number for
|
|
28
|
+
* `modelStreamIdleTimeoutMs` passes this explicitly.
|
|
29
|
+
*/
|
|
30
|
+
export const piHttpIdleTimeoutMs = 300_000;
|
|
31
|
+
/**
|
|
32
|
+
* Grace period a release waits for a provider iterator that ignores abort,
|
|
33
|
+
* so a timed-out run's scope close never hangs. This is a Mule transport
|
|
34
|
+
* detail with no Pi equivalent, so it stays a constant, not host config.
|
|
35
|
+
*/
|
|
36
|
+
const providerIteratorReleaseGraceMs = 1_000;
|
|
37
|
+
/** Apply Pi's maintained context-overflow semantics to an exact Pi message. */
|
|
38
|
+
export function isContextOverflow(message, contextWindow) {
|
|
39
|
+
return piIsContextOverflow(message, contextWindow);
|
|
40
|
+
}
|
|
41
|
+
const failure = (phase, message, cause) => ({ _tag: "MuleError", phase, message, cause });
|
|
42
|
+
const muleErrorSchema = Schema.Struct({
|
|
43
|
+
_tag: Schema.Literal("MuleError"),
|
|
44
|
+
phase: Schema.Literals(["model", "tool", "control", "listener"]),
|
|
45
|
+
message: Schema.String,
|
|
46
|
+
cause: Schema.optionalKey(Schema.Unknown),
|
|
47
|
+
});
|
|
48
|
+
const isMuleError = (value) => Schema.is(muleErrorSchema)(value);
|
|
49
|
+
const identity = (kind, parent) => ({ kind, value: `mule_${kind}_${nanoid()}`, parent });
|
|
50
|
+
const noListener = () => Effect.void;
|
|
51
|
+
function resultMessage(call, result) {
|
|
52
|
+
const message = {
|
|
53
|
+
role: "toolResult",
|
|
54
|
+
toolCallId: call.id,
|
|
55
|
+
toolName: call.name,
|
|
56
|
+
content: result.content,
|
|
57
|
+
isError: result.isError,
|
|
58
|
+
timestamp: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
if (result.details !== undefined)
|
|
61
|
+
message.details = result.details;
|
|
62
|
+
if (result.usage !== undefined)
|
|
63
|
+
message.usage = result.usage;
|
|
64
|
+
return message;
|
|
65
|
+
}
|
|
66
|
+
const errorResult = (message, terminate) => ({
|
|
67
|
+
content: [{ type: "text", text: message }],
|
|
68
|
+
isError: true,
|
|
69
|
+
terminate,
|
|
70
|
+
});
|
|
71
|
+
function selectedTool(tools, call) {
|
|
72
|
+
return tools.find((tool) => tool.descriptor.name === call.name);
|
|
73
|
+
}
|
|
74
|
+
function emitToolEnd(finalized, run, turn, emit) {
|
|
75
|
+
return emit({
|
|
76
|
+
type: "tool_execution_end",
|
|
77
|
+
run,
|
|
78
|
+
turn,
|
|
79
|
+
toolCall: finalized.identity,
|
|
80
|
+
call: finalized.call,
|
|
81
|
+
result: finalized.result,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function prepareTool(tool, call, run, turn, emit) {
|
|
85
|
+
const toolCall = identity("tool_call", turn);
|
|
86
|
+
return Effect.gen(function* () {
|
|
87
|
+
yield* emit({
|
|
88
|
+
type: "tool_execution_start",
|
|
89
|
+
replay: tool?.replay ?? "unsafe",
|
|
90
|
+
run,
|
|
91
|
+
turn,
|
|
92
|
+
toolCall,
|
|
93
|
+
call,
|
|
94
|
+
});
|
|
95
|
+
if (tool === undefined) {
|
|
96
|
+
const result = errorResult(`Tool "${call.name}" not found`);
|
|
97
|
+
const finalized = { call, identity: toolCall, result };
|
|
98
|
+
yield* emitToolEnd(finalized, run, turn, emit);
|
|
99
|
+
return { kind: "finalized", value: finalized };
|
|
100
|
+
}
|
|
101
|
+
const preparation = yield* Effect.result(tool.prepare(call));
|
|
102
|
+
if (Result.isFailure(preparation)) {
|
|
103
|
+
const result = errorResult(preparation.failure.message, preparation.failure.terminate);
|
|
104
|
+
const finalized = { call, identity: toolCall, result };
|
|
105
|
+
yield* emitToolEnd(finalized, run, turn, emit);
|
|
106
|
+
return { kind: "finalized", value: finalized };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
kind: "prepared",
|
|
110
|
+
call,
|
|
111
|
+
identity: toolCall,
|
|
112
|
+
tool: preparation.success,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function executePreparedTool(prepared, run, turn, emit) {
|
|
117
|
+
return Effect.gen(function* () {
|
|
118
|
+
let terminal;
|
|
119
|
+
let toolFailure;
|
|
120
|
+
// Only the tool stream's own failure becomes a model-visible error result.
|
|
121
|
+
// A listener failure while reporting progress fails the run.
|
|
122
|
+
yield* Stream.runForEach(Stream.result(prepared.tool.execute()), (part) => {
|
|
123
|
+
if (Result.isFailure(part)) {
|
|
124
|
+
toolFailure = part.failure;
|
|
125
|
+
return Effect.void;
|
|
126
|
+
}
|
|
127
|
+
if (terminal !== undefined)
|
|
128
|
+
return Effect.void;
|
|
129
|
+
if (part.success.type === "result") {
|
|
130
|
+
terminal = part.success.result;
|
|
131
|
+
return Effect.void;
|
|
132
|
+
}
|
|
133
|
+
return emit({
|
|
134
|
+
type: "tool_execution_update",
|
|
135
|
+
run,
|
|
136
|
+
turn,
|
|
137
|
+
toolCall: prepared.identity,
|
|
138
|
+
call: prepared.call,
|
|
139
|
+
result: part.success.result,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
const result = toolFailure !== undefined
|
|
143
|
+
? errorResult(toolFailure.message)
|
|
144
|
+
: (terminal ??
|
|
145
|
+
errorResult(`Tool "${prepared.call.name}" returned no result`));
|
|
146
|
+
const finalized = {
|
|
147
|
+
call: prepared.call,
|
|
148
|
+
identity: prepared.identity,
|
|
149
|
+
result,
|
|
150
|
+
};
|
|
151
|
+
yield* emitToolEnd(finalized, run, turn, emit);
|
|
152
|
+
return finalized;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function emitToolResult(executed, run, turn, emit) {
|
|
156
|
+
const message = resultMessage(executed.call, executed.result);
|
|
157
|
+
const messageIdentity = identity("tool_result", executed.identity);
|
|
158
|
+
return Effect.gen(function* () {
|
|
159
|
+
yield* emit({
|
|
160
|
+
type: "message_start",
|
|
161
|
+
run,
|
|
162
|
+
turn,
|
|
163
|
+
message: messageIdentity,
|
|
164
|
+
value: message,
|
|
165
|
+
});
|
|
166
|
+
yield* emit({
|
|
167
|
+
type: "message_end",
|
|
168
|
+
run,
|
|
169
|
+
turn,
|
|
170
|
+
message: messageIdentity,
|
|
171
|
+
value: message,
|
|
172
|
+
});
|
|
173
|
+
return message;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function executeToolBatch(calls, input, run, turn, emit) {
|
|
177
|
+
const tools = input.tools ?? [];
|
|
178
|
+
const sequential = input.toolExecution === "sequential" ||
|
|
179
|
+
calls.some((call) => selectedTool(tools, call)?.executionMode === "sequential");
|
|
180
|
+
return Effect.gen(function* () {
|
|
181
|
+
const executeEntry = (entry) => entry.kind === "finalized"
|
|
182
|
+
? Effect.succeed(entry.value)
|
|
183
|
+
: executePreparedTool(entry, run, turn, emit);
|
|
184
|
+
const finalized = sequential
|
|
185
|
+
? yield* Effect.forEach(calls, (call) => Effect.flatMap(prepareTool(selectedTool(tools, call), call, run, turn, emit), executeEntry))
|
|
186
|
+
: yield* Effect.gen(function* () {
|
|
187
|
+
const entries = yield* Effect.forEach(calls, (call) => prepareTool(selectedTool(tools, call), call, run, turn, emit));
|
|
188
|
+
return yield* Effect.all(entries.map(executeEntry), {
|
|
189
|
+
concurrency: "unbounded",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
const messages = yield* Effect.forEach(finalized, (result) => emitToolResult(result, run, turn, emit));
|
|
193
|
+
return {
|
|
194
|
+
messages,
|
|
195
|
+
terminate: finalized.length > 0 &&
|
|
196
|
+
finalized.every((result) => result.result.terminate === true),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const truncatedToolResult = (call) => errorResult(`Tool call "${call.name}" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.`);
|
|
201
|
+
function failTruncatedToolCalls(calls, run, turn, emit) {
|
|
202
|
+
return Effect.gen(function* () {
|
|
203
|
+
const finalized = yield* Effect.forEach(calls, (call) => Effect.gen(function* () {
|
|
204
|
+
const toolCall = identity("tool_call", turn);
|
|
205
|
+
const result = truncatedToolResult(call);
|
|
206
|
+
const value = { call, identity: toolCall, result };
|
|
207
|
+
yield* emit({
|
|
208
|
+
type: "tool_execution_start",
|
|
209
|
+
replay: "unsafe",
|
|
210
|
+
run,
|
|
211
|
+
turn,
|
|
212
|
+
toolCall,
|
|
213
|
+
call,
|
|
214
|
+
});
|
|
215
|
+
yield* emitToolEnd(value, run, turn, emit);
|
|
216
|
+
return value;
|
|
217
|
+
}));
|
|
218
|
+
return yield* Effect.forEach(finalized, (result) => emitToolResult(result, run, turn, emit));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function pullPiEvent(iterator, controller) {
|
|
222
|
+
return Effect.tryPromise({
|
|
223
|
+
try: (signal) => {
|
|
224
|
+
const abort = () => {
|
|
225
|
+
controller.abort(signal.reason);
|
|
226
|
+
};
|
|
227
|
+
if (signal.aborted)
|
|
228
|
+
abort();
|
|
229
|
+
else
|
|
230
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
231
|
+
return Promise.resolve(iterator.next()).finally(() => {
|
|
232
|
+
signal.removeEventListener("abort", abort);
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
catch: (cause) => failure("model", "Pi model stream failed", cause),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function withModelStreamDeadline(effect, timeoutMs) {
|
|
239
|
+
if (timeoutMs === undefined)
|
|
240
|
+
return effect;
|
|
241
|
+
return effect.pipe(Effect.timeoutOrElse({
|
|
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.`)),
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
function streamTurn(input, context, run, turn, emit, attempt) {
|
|
247
|
+
const messageIdentity = identity("message", turn);
|
|
248
|
+
return Effect.scoped(Effect.gen(function* () {
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
251
|
+
controller.abort("Mule model scope closed");
|
|
252
|
+
}));
|
|
253
|
+
const resolvedApiKey = (input.resolveApiKey === undefined
|
|
254
|
+
? undefined
|
|
255
|
+
: yield* input.resolveApiKey(input.model.provider)) ??
|
|
256
|
+
input.streamOptions?.apiKey;
|
|
257
|
+
const options = {
|
|
258
|
+
...input.streamOptions,
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
};
|
|
261
|
+
if (resolvedApiKey !== undefined)
|
|
262
|
+
options.apiKey = resolvedApiKey;
|
|
263
|
+
const systemPrompt = input.prepareSystemPrompt === undefined
|
|
264
|
+
? input.systemPrompt
|
|
265
|
+
: yield* input.prepareSystemPrompt();
|
|
266
|
+
if (input.prepareSystemPrompt !== undefined)
|
|
267
|
+
yield* emit({ type: "model_prompt", run, turn, attempt, systemPrompt });
|
|
268
|
+
const piContext = {
|
|
269
|
+
systemPrompt,
|
|
270
|
+
messages: context,
|
|
271
|
+
};
|
|
272
|
+
const descriptors = (input.tools ?? []).map((tool) => tool.descriptor);
|
|
273
|
+
if (descriptors.length > 0)
|
|
274
|
+
piContext.tools = descriptors;
|
|
275
|
+
const response = yield* withModelStreamDeadline(Effect.tryPromise({
|
|
276
|
+
try: async () => {
|
|
277
|
+
const stream = input.stream ??
|
|
278
|
+
(await import("@earendil-works/pi-ai/compat")).streamSimple;
|
|
279
|
+
return stream(input.model, normalizeContext(piContext), options);
|
|
280
|
+
},
|
|
281
|
+
catch: (cause) => failure("model", "Pi model stream could not start", cause),
|
|
282
|
+
}), input.modelStreamIdleTimeoutMs);
|
|
283
|
+
const streamState = {
|
|
284
|
+
started: false,
|
|
285
|
+
terminal: undefined,
|
|
286
|
+
};
|
|
287
|
+
const iterator = yield* Effect.acquireRelease(Effect.sync(() => response[Symbol.asyncIterator]()), (active) => Effect.tryPromise({
|
|
288
|
+
try: () => {
|
|
289
|
+
controller.abort("Mule model stream closed");
|
|
290
|
+
// Provider iterators can ignore abort while a read is pending.
|
|
291
|
+
// Releasing a timed-out run must not await that iterator forever.
|
|
292
|
+
return new Promise((resolve) => {
|
|
293
|
+
const timer = setTimeout(resolve, providerIteratorReleaseGraceMs);
|
|
294
|
+
Promise.resolve(active.return?.()).then(() => {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
resolve();
|
|
297
|
+
}, () => {
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
resolve();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
catch: (cause) => failure("model", "Pi model stream failed", cause),
|
|
304
|
+
}).pipe(Effect.ignore));
|
|
305
|
+
const observeEvent = (event) => {
|
|
306
|
+
switch (event.type) {
|
|
307
|
+
case "start": {
|
|
308
|
+
context.push(event.partial);
|
|
309
|
+
streamState.started = true;
|
|
310
|
+
return emit({
|
|
311
|
+
type: "message_start",
|
|
312
|
+
run,
|
|
313
|
+
turn,
|
|
314
|
+
message: messageIdentity,
|
|
315
|
+
value: event.partial,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
case "text_start":
|
|
319
|
+
case "text_delta":
|
|
320
|
+
case "text_end":
|
|
321
|
+
case "thinking_start":
|
|
322
|
+
case "thinking_delta":
|
|
323
|
+
case "thinking_end":
|
|
324
|
+
case "toolcall_start":
|
|
325
|
+
case "toolcall_delta":
|
|
326
|
+
case "toolcall_end": {
|
|
327
|
+
if (streamState.started)
|
|
328
|
+
context[context.length - 1] = event.partial;
|
|
329
|
+
return emit({
|
|
330
|
+
type: "message_update",
|
|
331
|
+
run,
|
|
332
|
+
turn,
|
|
333
|
+
message: messageIdentity,
|
|
334
|
+
value: event.partial,
|
|
335
|
+
event,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
case "done": {
|
|
339
|
+
streamState.terminal = event.message;
|
|
340
|
+
return Effect.void;
|
|
341
|
+
}
|
|
342
|
+
case "error": {
|
|
343
|
+
streamState.terminal = event.error;
|
|
344
|
+
return Effect.void;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const pull = () => withModelStreamDeadline(pullPiEvent(iterator, controller), input.modelStreamIdleTimeoutMs);
|
|
349
|
+
let next = yield* pull();
|
|
350
|
+
while (!next.done) {
|
|
351
|
+
yield* observeEvent(next.value);
|
|
352
|
+
next = yield* pull();
|
|
353
|
+
}
|
|
354
|
+
const completed = streamState.terminal ??
|
|
355
|
+
(yield* withModelStreamDeadline(Effect.tryPromise({
|
|
356
|
+
try: () => response.result(),
|
|
357
|
+
catch: (cause) => failure("model", "Pi model stream did not settle", cause),
|
|
358
|
+
}), input.modelStreamIdleTimeoutMs));
|
|
359
|
+
if (streamState.started)
|
|
360
|
+
context[context.length - 1] = completed;
|
|
361
|
+
else {
|
|
362
|
+
context.push(completed);
|
|
363
|
+
yield* emit({
|
|
364
|
+
type: "message_start",
|
|
365
|
+
run,
|
|
366
|
+
turn,
|
|
367
|
+
message: messageIdentity,
|
|
368
|
+
value: completed,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
yield* emit({
|
|
372
|
+
type: "message_end",
|
|
373
|
+
run,
|
|
374
|
+
turn,
|
|
375
|
+
message: messageIdentity,
|
|
376
|
+
value: completed,
|
|
377
|
+
});
|
|
378
|
+
return { identity: messageIdentity, message: completed };
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Run one assistant call with Pi's bounded retry (`retryAssistantCall`)
|
|
383
|
+
* around it. `input.retry` undefined or disabled means `retryAssistantCall`
|
|
384
|
+
* calls `streamTurn` exactly once and returns its result unchanged.
|
|
385
|
+
*
|
|
386
|
+
* Each attempt streams and is recorded exactly like any other turn (so a
|
|
387
|
+
* failed attempt stays in the retained event log for inspection), but a
|
|
388
|
+
* failed attempt's message is removed from `context` before the next
|
|
389
|
+
* attempt runs, so it is never replayed into the model's next call.
|
|
390
|
+
*/
|
|
391
|
+
function streamTurnWithRetry(input, context, run, turn, emit) {
|
|
392
|
+
return Effect.gen(function* () {
|
|
393
|
+
const services = yield* Effect.context();
|
|
394
|
+
const runFork = Effect.runForkWith(services);
|
|
395
|
+
const runPromise = Effect.runPromiseWith(services);
|
|
396
|
+
let latest;
|
|
397
|
+
let attempt = 0;
|
|
398
|
+
let retrySignal;
|
|
399
|
+
const reportRetry = (event) => input.onRetry === undefined
|
|
400
|
+
? undefined
|
|
401
|
+
: runPromise(input.onRetry(event));
|
|
402
|
+
const callbacks = {
|
|
403
|
+
onRetryScheduled: async (attempt, maxAttempts, delayMs, errorMessage) => {
|
|
404
|
+
// Pi owns the only retry loop and its exponential schedule. A clean
|
|
405
|
+
// gateway 429 can request a longer delay; extend that same scheduled
|
|
406
|
+
// sleep (capped), never dispatch a second transport-level retry.
|
|
407
|
+
const after = latest === undefined
|
|
408
|
+
? undefined
|
|
409
|
+
: input.retryAfterMs?.(latest.message);
|
|
410
|
+
const extra = after !== undefined &&
|
|
411
|
+
Number.isSafeInteger(after) &&
|
|
412
|
+
after >= 0 &&
|
|
413
|
+
after <= 60_000
|
|
414
|
+
? Math.max(0, after - delayMs)
|
|
415
|
+
: 0;
|
|
416
|
+
await reportRetry({
|
|
417
|
+
type: "retry_scheduled",
|
|
418
|
+
run,
|
|
419
|
+
turn,
|
|
420
|
+
attempt,
|
|
421
|
+
maxAttempts,
|
|
422
|
+
delayMs: delayMs + extra,
|
|
423
|
+
errorMessage,
|
|
424
|
+
});
|
|
425
|
+
if (extra > 0)
|
|
426
|
+
await new Promise((resolve) => {
|
|
427
|
+
const timer = setTimeout(() => {
|
|
428
|
+
retrySignal?.removeEventListener("abort", abort);
|
|
429
|
+
resolve();
|
|
430
|
+
}, extra);
|
|
431
|
+
const abort = () => {
|
|
432
|
+
clearTimeout(timer);
|
|
433
|
+
resolve();
|
|
434
|
+
};
|
|
435
|
+
retrySignal?.addEventListener("abort", abort, { once: true });
|
|
436
|
+
if (retrySignal?.aborted)
|
|
437
|
+
abort();
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
onRetryAttemptStart: () => reportRetry({ type: "retry_attempt_started", run, turn, attempt }),
|
|
441
|
+
onRetryFinished: (success, attempt, finalError) => reportRetry(finalError === undefined
|
|
442
|
+
? { type: "retry_finished", run, turn, attempt, success }
|
|
443
|
+
: {
|
|
444
|
+
type: "retry_finished",
|
|
445
|
+
run,
|
|
446
|
+
turn,
|
|
447
|
+
attempt,
|
|
448
|
+
success,
|
|
449
|
+
finalError,
|
|
450
|
+
}),
|
|
451
|
+
};
|
|
452
|
+
const result = yield* Effect.tryPromise({
|
|
453
|
+
try: (signal) => {
|
|
454
|
+
retrySignal = signal;
|
|
455
|
+
// `retryAssistantCall` only threads `signal` through its own
|
|
456
|
+
// backoff sleep, never into an in-flight `produce`. Fork `streamTurn`
|
|
457
|
+
// as its own fiber and interrupt it on `signal` so a cancellation
|
|
458
|
+
// reaches an active call too, exactly like interrupting this fiber
|
|
459
|
+
// reached `streamTurn` before retry wrapped it.
|
|
460
|
+
const produce = async () => {
|
|
461
|
+
if (latest !== undefined) {
|
|
462
|
+
const index = context.indexOf(latest.message);
|
|
463
|
+
if (index !== -1)
|
|
464
|
+
context.splice(index, 1);
|
|
465
|
+
}
|
|
466
|
+
const fiber = runFork(streamTurn(input, context, run, turn, emit, ++attempt));
|
|
467
|
+
const interrupt = () => {
|
|
468
|
+
runFork(Fiber.interrupt(fiber));
|
|
469
|
+
};
|
|
470
|
+
if (signal.aborted)
|
|
471
|
+
interrupt();
|
|
472
|
+
else
|
|
473
|
+
signal.addEventListener("abort", interrupt, { once: true });
|
|
474
|
+
const exit = await runPromise(Fiber.await(fiber));
|
|
475
|
+
signal.removeEventListener("abort", interrupt);
|
|
476
|
+
// `streamTurn`'s own Effect failures (a transport exception, a
|
|
477
|
+
// listener failure, or this fiber's own interruption) are not
|
|
478
|
+
// Pi-protocol error messages for `retryAssistantCall` to classify:
|
|
479
|
+
// unwrap and rethrow the original so the surrounding
|
|
480
|
+
// `Effect.tryPromise` reports it unchanged, not behind a new
|
|
481
|
+
// "Pi retry loop failed" layer.
|
|
482
|
+
if (Exit.isFailure(exit)) {
|
|
483
|
+
const error = Cause.findErrorOption(exit.cause);
|
|
484
|
+
throw Option.isSome(error) ? error.value : Cause.squash(exit.cause);
|
|
485
|
+
}
|
|
486
|
+
latest = exit.value;
|
|
487
|
+
return latest.message;
|
|
488
|
+
};
|
|
489
|
+
return retryAssistantCall(produce, input.retry, signal, callbacks);
|
|
490
|
+
},
|
|
491
|
+
catch: (cause) => isMuleError(cause)
|
|
492
|
+
? cause
|
|
493
|
+
: failure("model", "Pi retry loop failed", cause),
|
|
494
|
+
});
|
|
495
|
+
// `produce` always runs at least once before `retryAssistantCall`
|
|
496
|
+
// settles, so `latest` is always set here.
|
|
497
|
+
if (latest === undefined)
|
|
498
|
+
return yield* Effect.die("streamTurnWithRetry: retryAssistantCall settled without calling produce");
|
|
499
|
+
const settled = latest;
|
|
500
|
+
if (result !== settled.message) {
|
|
501
|
+
// Pi normalizes an abort during backoff sleep into a fresh
|
|
502
|
+
// AssistantMessage (the failed attempt's content, `stopReason`
|
|
503
|
+
// forced to "aborted") instead of replaying `produce`. Reconcile the
|
|
504
|
+
// dangling failed entry `context` is still holding with that value.
|
|
505
|
+
const index = context.indexOf(settled.message);
|
|
506
|
+
if (index !== -1)
|
|
507
|
+
context[index] = result;
|
|
508
|
+
}
|
|
509
|
+
return { identity: settled.identity, message: result };
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
const statusFor = (message) => message.stopReason === "aborted"
|
|
513
|
+
? "cancelled"
|
|
514
|
+
: message.stopReason === "error"
|
|
515
|
+
? "failed"
|
|
516
|
+
: "completed";
|
|
517
|
+
function emitPendingMessages(messages, context, newMessages, run, turn, emit) {
|
|
518
|
+
return Effect.forEach(messages, (message) => Effect.gen(function* () {
|
|
519
|
+
const messageIdentity = identity("message", turn);
|
|
520
|
+
context.push(message);
|
|
521
|
+
newMessages.push(message);
|
|
522
|
+
yield* emit({
|
|
523
|
+
type: "message_start",
|
|
524
|
+
run,
|
|
525
|
+
turn,
|
|
526
|
+
message: messageIdentity,
|
|
527
|
+
value: message,
|
|
528
|
+
});
|
|
529
|
+
yield* emit({
|
|
530
|
+
type: "message_end",
|
|
531
|
+
run,
|
|
532
|
+
turn,
|
|
533
|
+
message: messageIdentity,
|
|
534
|
+
value: message,
|
|
535
|
+
});
|
|
536
|
+
})).pipe(Effect.asVoid);
|
|
537
|
+
}
|
|
538
|
+
function completeLoopTurn(input, context, newMessages, run, turn, pending, emit) {
|
|
539
|
+
return Effect.gen(function* () {
|
|
540
|
+
yield* emit({ type: "turn_start", run, turn });
|
|
541
|
+
yield* emitPendingMessages(pending, context, newMessages, run, turn, emit);
|
|
542
|
+
const streamed = yield* streamTurnWithRetry(input, context, run, turn, emit);
|
|
543
|
+
// Pi keeps partially streamed tool calls on an aborted or errored message.
|
|
544
|
+
// Like Pi's loop, the turn ends there: nothing executes and no tool result
|
|
545
|
+
// pretends a call ran. The run then settles with the honest terminal.
|
|
546
|
+
const interrupted = statusFor(streamed.message) !== "completed";
|
|
547
|
+
const calls = streamed.message.content.filter((content) => content.type === "toolCall");
|
|
548
|
+
const batch = calls.length === 0 || interrupted
|
|
549
|
+
? { messages: [], terminate: false }
|
|
550
|
+
: streamed.message.stopReason === "length"
|
|
551
|
+
? {
|
|
552
|
+
messages: yield* failTruncatedToolCalls(calls, run, turn, emit),
|
|
553
|
+
terminate: false,
|
|
554
|
+
}
|
|
555
|
+
: yield* executeToolBatch(calls, input, run, turn, emit);
|
|
556
|
+
context.push(...batch.messages);
|
|
557
|
+
newMessages.push(streamed.message, ...batch.messages);
|
|
558
|
+
yield* emit({
|
|
559
|
+
type: "turn_end",
|
|
560
|
+
run,
|
|
561
|
+
turn,
|
|
562
|
+
message: streamed.message,
|
|
563
|
+
toolResults: batch.messages,
|
|
564
|
+
});
|
|
565
|
+
return {
|
|
566
|
+
message: streamed.message,
|
|
567
|
+
toolResults: batch.messages,
|
|
568
|
+
terminate: batch.terminate,
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/** Resolve only missing results. A stored and current safe policy are both required. */
|
|
573
|
+
function resumeTurn(input, resume, context, newMessages, run, emit) {
|
|
574
|
+
const turn = identity("turn", run);
|
|
575
|
+
return Effect.gen(function* () {
|
|
576
|
+
// Do not emit turn_start: the assistant response is already durable and
|
|
577
|
+
// no new model request is being dispatched for this recovered boundary.
|
|
578
|
+
if (statusFor(resume.message) !== "completed")
|
|
579
|
+
return {
|
|
580
|
+
message: resume.message,
|
|
581
|
+
toolResults: [],
|
|
582
|
+
terminate: true,
|
|
583
|
+
};
|
|
584
|
+
const recovered = yield* Effect.forEach(resume.tools, (checkpoint) => Effect.gen(function* () {
|
|
585
|
+
if (checkpoint.state === "completed" &&
|
|
586
|
+
checkpoint.message !== undefined)
|
|
587
|
+
return {
|
|
588
|
+
message: checkpoint.message,
|
|
589
|
+
terminate: checkpoint.result.terminate === true,
|
|
590
|
+
recorded: true,
|
|
591
|
+
};
|
|
592
|
+
const tool = selectedTool(input.tools ?? [], checkpoint.call);
|
|
593
|
+
let finalized;
|
|
594
|
+
if (resume.message.stopReason !== "length" &&
|
|
595
|
+
(checkpoint.state === "pending" ||
|
|
596
|
+
(checkpoint.state === "started" &&
|
|
597
|
+
checkpoint.replay === "safe" &&
|
|
598
|
+
tool?.replay === "safe"))) {
|
|
599
|
+
const prepared = yield* prepareTool(tool, checkpoint.call, run, turn, emit);
|
|
600
|
+
finalized =
|
|
601
|
+
prepared.kind === "finalized"
|
|
602
|
+
? prepared.value
|
|
603
|
+
: yield* executePreparedTool(prepared, run, turn, emit);
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
finalized = {
|
|
607
|
+
call: checkpoint.call,
|
|
608
|
+
identity: identity("tool_call", turn),
|
|
609
|
+
result: checkpoint.state === "completed"
|
|
610
|
+
? checkpoint.result
|
|
611
|
+
: checkpoint.state === "pending" &&
|
|
612
|
+
resume.message.stopReason === "length"
|
|
613
|
+
? truncatedToolResult(checkpoint.call)
|
|
614
|
+
: errorResult("Interrupted by a restart; it may or may not have completed; check before retrying."),
|
|
615
|
+
};
|
|
616
|
+
yield* emit({
|
|
617
|
+
type: "tool_execution_start",
|
|
618
|
+
replay: "unsafe",
|
|
619
|
+
run,
|
|
620
|
+
turn,
|
|
621
|
+
toolCall: finalized.identity,
|
|
622
|
+
call: finalized.call,
|
|
623
|
+
});
|
|
624
|
+
yield* emitToolEnd(finalized, run, turn, emit);
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
message: yield* emitToolResult(finalized, run, turn, emit),
|
|
628
|
+
terminate: finalized.result.terminate === true,
|
|
629
|
+
recorded: false,
|
|
630
|
+
};
|
|
631
|
+
}));
|
|
632
|
+
const messages = recovered
|
|
633
|
+
.filter(({ recorded }) => !recorded)
|
|
634
|
+
.map(({ message }) => message);
|
|
635
|
+
context.push(...messages);
|
|
636
|
+
newMessages.push(...messages);
|
|
637
|
+
return {
|
|
638
|
+
message: resume.message,
|
|
639
|
+
toolResults: recovered.map(({ message }) => message),
|
|
640
|
+
terminate: recovered.length > 0 && recovered.every(({ terminate }) => terminate),
|
|
641
|
+
};
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
function selectNextTurn(input, turn, context, newMessages) {
|
|
645
|
+
return Effect.gen(function* () {
|
|
646
|
+
if (input.shouldStopAfterTurn !== undefined &&
|
|
647
|
+
(yield* input.shouldStopAfterTurn({
|
|
648
|
+
message: turn.message,
|
|
649
|
+
toolResults: turn.toolResults,
|
|
650
|
+
context,
|
|
651
|
+
newMessages,
|
|
652
|
+
})))
|
|
653
|
+
return { continue: false, pending: [] };
|
|
654
|
+
const steering = input.getSteeringMessages === undefined
|
|
655
|
+
? []
|
|
656
|
+
: [...(yield* input.getSteeringMessages())];
|
|
657
|
+
const hasToolCalls = turn.message.content.some((content) => content.type === "toolCall");
|
|
658
|
+
if ((hasToolCalls && !turn.terminate) || steering.length > 0)
|
|
659
|
+
return { continue: true, pending: steering };
|
|
660
|
+
const followUps = input.getFollowUpMessages === undefined
|
|
661
|
+
? []
|
|
662
|
+
: [...(yield* input.getFollowUpMessages())];
|
|
663
|
+
return { continue: followUps.length > 0, pending: followUps };
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
function finishRun(run, terminal, message, messages, status, emit) {
|
|
667
|
+
return Effect.gen(function* () {
|
|
668
|
+
yield* emit({ type: "run_end", run, terminal, outcome: status });
|
|
669
|
+
return { status, run, terminal, message, messages };
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
/** Run Pi's model/tool loop inside Effect without changing Pi's values. */
|
|
673
|
+
export function run(input, listener = noListener) {
|
|
674
|
+
return Effect.gen(function* () {
|
|
675
|
+
const semaphore = yield* Semaphore.make(1);
|
|
676
|
+
const lifecycle = { started: false, ended: false };
|
|
677
|
+
const emit = (event) => Effect.suspend(() => {
|
|
678
|
+
if (event.type === "run_end")
|
|
679
|
+
lifecycle.ended = true;
|
|
680
|
+
return semaphore.withPermits(1)(listener(event));
|
|
681
|
+
}).pipe(Effect.tap(() => Effect.sync(() => {
|
|
682
|
+
if (event.type === "run_start")
|
|
683
|
+
lifecycle.started = true;
|
|
684
|
+
})));
|
|
685
|
+
const runIdentity = identity("run");
|
|
686
|
+
const terminalIdentity = identity("terminal", runIdentity);
|
|
687
|
+
return yield* runLoop(input, emit, runIdentity, terminalIdentity).pipe(Effect.onExit((exit) => {
|
|
688
|
+
const outcome = abnormalOutcome(exit);
|
|
689
|
+
if (!lifecycle.started || lifecycle.ended || outcome === undefined)
|
|
690
|
+
return Effect.void;
|
|
691
|
+
// The exit is already decided; a listener failure here cannot change it.
|
|
692
|
+
return emit({
|
|
693
|
+
type: "run_end",
|
|
694
|
+
run: runIdentity,
|
|
695
|
+
terminal: terminalIdentity,
|
|
696
|
+
outcome,
|
|
697
|
+
}).pipe(Effect.ignore);
|
|
698
|
+
}));
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/** Terminal outcome owed to the listener for an exit that produced no outcome. */
|
|
702
|
+
function abnormalOutcome(exit) {
|
|
703
|
+
if (Exit.isSuccess(exit))
|
|
704
|
+
return undefined;
|
|
705
|
+
if (Cause.hasInterruptsOnly(exit.cause))
|
|
706
|
+
return "cancelled";
|
|
707
|
+
const error = Cause.findErrorOption(exit.cause);
|
|
708
|
+
// A failed listener cannot be trusted to record its own terminal.
|
|
709
|
+
if (Option.isSome(error) && error.value.phase === "listener")
|
|
710
|
+
return undefined;
|
|
711
|
+
return "failed";
|
|
712
|
+
}
|
|
713
|
+
function runLoop(input, emit, runIdentity, terminalIdentity) {
|
|
714
|
+
return Effect.gen(function* () {
|
|
715
|
+
const context = [...input.messages];
|
|
716
|
+
const newMessages = [];
|
|
717
|
+
let pending = input.getSteeringMessages === undefined
|
|
718
|
+
? []
|
|
719
|
+
: [...(yield* input.getSteeringMessages())];
|
|
720
|
+
let finalMessage;
|
|
721
|
+
let continueLoop;
|
|
722
|
+
yield* emit({ type: "run_start", run: runIdentity });
|
|
723
|
+
if (input.resume !== undefined) {
|
|
724
|
+
const resumed = yield* resumeTurn(input, input.resume, context, newMessages, runIdentity, emit);
|
|
725
|
+
const status = statusFor(resumed.message);
|
|
726
|
+
const next = status === "completed"
|
|
727
|
+
? yield* selectNextTurn(input, resumed, context, newMessages)
|
|
728
|
+
: { continue: false, pending: [] };
|
|
729
|
+
if (!next.continue && pending.length === 0)
|
|
730
|
+
return yield* finishRun(runIdentity, terminalIdentity, resumed.message, newMessages, status, emit);
|
|
731
|
+
pending.push(...next.pending);
|
|
732
|
+
}
|
|
733
|
+
do {
|
|
734
|
+
const completed = yield* completeLoopTurn(input, context, newMessages, runIdentity, identity("turn", runIdentity), pending, emit);
|
|
735
|
+
finalMessage = completed.message;
|
|
736
|
+
const status = statusFor(completed.message);
|
|
737
|
+
if (status !== "completed") {
|
|
738
|
+
return yield* finishRun(runIdentity, terminalIdentity, completed.message, newMessages, status, emit);
|
|
739
|
+
}
|
|
740
|
+
// Tool results always continue the run: this is where Pi checks its
|
|
741
|
+
// context before the next assistant response (Pi 0.84.4+).
|
|
742
|
+
if (input.prepareNextTurn !== undefined &&
|
|
743
|
+
!completed.terminate &&
|
|
744
|
+
completed.message.content.some((part) => part.type === "toolCall")) {
|
|
745
|
+
const replaced = yield* input.prepareNextTurn({
|
|
746
|
+
message: completed.message,
|
|
747
|
+
toolResults: completed.toolResults,
|
|
748
|
+
context,
|
|
749
|
+
newMessages,
|
|
750
|
+
});
|
|
751
|
+
if (replaced !== undefined)
|
|
752
|
+
context.splice(0, context.length, ...replaced);
|
|
753
|
+
}
|
|
754
|
+
const next = yield* selectNextTurn(input, completed, context, newMessages);
|
|
755
|
+
pending = [...next.pending];
|
|
756
|
+
continueLoop = next.continue;
|
|
757
|
+
} while (continueLoop);
|
|
758
|
+
return yield* finishRun(runIdentity, terminalIdentity, finalMessage, newMessages, "completed", emit);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
/** Canonical Effect-native Mule value. */
|
|
762
|
+
export const muleEffect = { run };
|