@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/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 };