pi-aside 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fork.ts ADDED
@@ -0,0 +1,520 @@
1
+ // The aside request (docs/design.md §4.2, §4.8): conversion under the frozen recipe, the dry run B0, ΔH, and the
2
+ // send of B1 whose onPayload checks C2 and C3 and returns F. T1 rebuilds when any check fails.
3
+
4
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
5
+ import { normalizeContext } from "@earendil-works/pi-ai";
6
+ import { convertToLlm, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
7
+ import type { Observation, Recipe } from "./observe.ts";
8
+ import {
9
+ applyDelta,
10
+ checkC1,
11
+ checkC2,
12
+ checkC3,
13
+ countMarkers,
14
+ type Delta,
15
+ deepEqual,
16
+ diffTopLevel,
17
+ differsOnlyInConfig,
18
+ type Payload,
19
+ splitList,
20
+ T0_APIS,
21
+ } from "./payload.ts";
22
+ import { OWN_REQUEST } from "./tap.ts";
23
+ import { envSnapshot, envUnchanged, mergeAttributionHeaders, requestOptions } from "./transport.ts";
24
+ import type { SettingsManager } from "@earendil-works/pi-coding-agent";
25
+
26
+ // biome-ignore lint: Pi's model, registry and stream types are used structurally.
27
+ type Any = any;
28
+
29
+ export interface ForkEnv {
30
+ registry: Any;
31
+ settings: () => SettingsManager;
32
+ sessionManager: Any;
33
+ /** The selected model and thinking level, for T1 without an observation. */
34
+ selectedModel: () => Any;
35
+ thinkingLevel: () => string;
36
+ systemPrompt: () => string;
37
+ }
38
+
39
+ const IMAGE_DISABLED = "Image reading is disabled.";
40
+
41
+ /** Pi's convertToLlm wrapper with image blocking (sdk.js), then normalizeContext. */
42
+ export function toContext(messages: unknown[], blockImages: boolean): Any {
43
+ let converted = convertToLlm(messages as AgentMessage[]) as Any[];
44
+ if (blockImages) {
45
+ converted = converted.map((msg) => {
46
+ if ((msg.role === "user" || msg.role === "toolResult") && Array.isArray(msg.content)) {
47
+ if (msg.content.some((c: Any) => c.type === "image")) {
48
+ const content = msg.content
49
+ .map((c: Any) => (c.type === "image" ? { type: "text", text: IMAGE_DISABLED } : c))
50
+ .filter(
51
+ (c: Any, i: number, arr: Any[]) =>
52
+ !(
53
+ c.type === "text" &&
54
+ c.text === IMAGE_DISABLED &&
55
+ i > 0 &&
56
+ arr[i - 1].type === "text" &&
57
+ arr[i - 1].text === IMAGE_DISABLED
58
+ ),
59
+ );
60
+ return { ...msg, content };
61
+ }
62
+ }
63
+ return msg;
64
+ });
65
+ }
66
+ return normalizeContext({ messages: converted });
67
+ }
68
+
69
+ /** G: the messages the branch gained since the observation's leaf, or undefined if the leaf left the branch. */
70
+ export function gainedSince(sessionManager: Any, leafId: string | null): unknown[] | undefined {
71
+ const branch: Any[] = sessionManager.getBranch();
72
+ let start = 0;
73
+ if (leafId !== null) {
74
+ const index = branch.findIndex((entry) => entry.id === leafId);
75
+ if (index < 0) return undefined;
76
+ start = index + 1;
77
+ }
78
+ return branch.slice(start).flatMap((entry) => sessionEntryToContextMessages(entry));
79
+ }
80
+
81
+ export const STILL_RUNNING = "(still running in the main session; its result is not available yet)";
82
+
83
+ /** Add a "still running" result for every tool call of the last assistant message that has none. */
84
+ export function withPlaceholders(messages: unknown[]): unknown[] {
85
+ const out = [...messages];
86
+ let lastAssistant = -1;
87
+ for (let i = out.length - 1; i >= 0; i--) {
88
+ if ((out[i] as Any).role === "assistant") {
89
+ lastAssistant = i;
90
+ break;
91
+ }
92
+ }
93
+ if (lastAssistant < 0) return out;
94
+ const calls = ((out[lastAssistant] as Any).content ?? []).filter((c: Any) => c?.type === "toolCall");
95
+ const answered = new Set(
96
+ out
97
+ .slice(lastAssistant + 1)
98
+ .filter((m: Any) => m.role === "toolResult")
99
+ .map((m: Any) => m.toolCallId),
100
+ );
101
+ for (const call of calls) {
102
+ if (answered.has(call.id)) continue;
103
+ out.push({
104
+ role: "toolResult",
105
+ toolCallId: call.id,
106
+ toolName: call.name,
107
+ content: [{ type: "text", text: STILL_RUNNING }],
108
+ isError: false,
109
+ timestamp: Date.now(),
110
+ });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ export class Fallback extends Error {}
116
+
117
+ export type Tier = "exact" | "rebuilt";
118
+
119
+ /** A fork: the base messages (T ++ G) and how requests over it are sent. */
120
+ export interface Fork {
121
+ tier: Tier;
122
+ /** Why T1 was used. */
123
+ reason?: string;
124
+ base: unknown[];
125
+ observation?: Observation;
126
+ recipe: {
127
+ provider: string;
128
+ modelId: string;
129
+ api: string;
130
+ reasoning: string | undefined;
131
+ sessionId: string;
132
+ transport: string | undefined;
133
+ thinkingBudgets: unknown;
134
+ blockImages: boolean;
135
+ };
136
+ /** The thinking level the aside sends with, when lower than the main session's (see `asideLevel`). */
137
+ effort?: string;
138
+ /** T0 only. */
139
+ b0?: Payload;
140
+ /** T0 with `effort`: the dry run of T at the aside's effort; it differs from B0 only in E. */
141
+ b0e?: Payload;
142
+ delta?: Delta;
143
+ modelSnapshot?: Any;
144
+ /** Number of entries in the session branch when the fork was made. */
145
+ branchLength: number;
146
+ /** SHA-256 of P's canonical JSON (T0). */
147
+ pHash?: string;
148
+ }
149
+
150
+ export interface SendResult {
151
+ message: Any;
152
+ tier: Tier;
153
+ reason?: string;
154
+ /** Markers in the request sent (Anthropic). */
155
+ markers?: number;
156
+ /** The payload actually sent. */
157
+ sent?: Payload;
158
+ }
159
+
160
+ /** The effort an aside asks for: quick answers, so its first words arrive soon. */
161
+ export const ASIDE_LEVEL = "low";
162
+ const LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
163
+
164
+ /**
165
+ * The thinking level for an aside: `ASIDE_LEVEL` when the main session's is higher and the model takes effort
166
+ * changes mid-conversation (`compat.supportsMidConvoEffort`: pi-ai sends the level as a trailing configuration item,
167
+ * after the cached prefix), otherwise the main session's level. A level the model maps to nothing is not used.
168
+ */
169
+ export function asideLevel(model: Any, level: string | undefined): string | undefined {
170
+ if (!level || model?.compat?.supportsMidConvoEffort !== true) return level;
171
+ const map = model.thinkingLevelMap;
172
+ if (map && typeof map === "object" && (map[ASIDE_LEVEL] === null || (Object.keys(map).length > 0 && !(ASIDE_LEVEL in map)))) {
173
+ return level;
174
+ }
175
+ const i = LEVELS.indexOf(level);
176
+ return i > LEVELS.indexOf(ASIDE_LEVEL) ? ASIDE_LEVEL : level;
177
+ }
178
+
179
+ function baseOptions(
180
+ env: ForkEnv,
181
+ fork: Fork,
182
+ model: Any,
183
+ signal: AbortSignal | undefined,
184
+ reasoning: string | undefined = fork.effort ?? fork.recipe.reasoning,
185
+ ): Any {
186
+ const settings = env.settings();
187
+ const opts = requestOptions(settings);
188
+ return {
189
+ reasoning,
190
+ sessionId: fork.recipe.sessionId,
191
+ transport: fork.recipe.transport,
192
+ thinkingBudgets: fork.recipe.thinkingBudgets,
193
+ maxRetryDelayMs: opts.maxRetryDelayMs,
194
+ timeoutMs: opts.timeoutMs,
195
+ websocketConnectTimeoutMs: opts.websocketConnectTimeoutMs,
196
+ maxRetries: opts.maxRetries,
197
+ transformHeaders: (headers: Record<string, unknown>) =>
198
+ mergeAttributionHeaders(model, settings, fork.recipe.sessionId, headers) ?? {},
199
+ signal,
200
+ [OWN_REQUEST]: true,
201
+ };
202
+ }
203
+
204
+ /** Run a stream to its end and return the final message (or the error message). */
205
+ async function finish(stream: Any): Promise<Any> {
206
+ for await (const _event of stream) {
207
+ // drained
208
+ }
209
+ return await stream.result();
210
+ }
211
+
212
+ /** B0: the dry conversion of T; onPayload records the payload and aborts before any request is sent. */
213
+ export async function dryRun(
214
+ env: ForkEnv,
215
+ fork: Fork,
216
+ model: Any,
217
+ messages: unknown[],
218
+ reasoning: string | undefined = fork.recipe.reasoning,
219
+ ): Promise<Payload> {
220
+ let captured: Payload | undefined;
221
+ const options = baseOptions(env, fork, model, undefined, reasoning);
222
+ options.onPayload = (payload: unknown) => {
223
+ captured = structuredClone(payload) as Payload;
224
+ throw new Fallback("dry run");
225
+ };
226
+ const message = await finish(env.registry.streamSimple(model, toContext(messages, fork.recipe.blockImages), options));
227
+ if (!captured) throw new Fallback(`the dry conversion failed: ${message?.errorMessage ?? "no payload"}`);
228
+ return captured;
229
+ }
230
+
231
+ async function sha256(text: string): Promise<string> {
232
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
233
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
234
+ }
235
+
236
+ function isStatelessTransport(api: string, transport: string | undefined): boolean {
237
+ if (api === "openai-codex-responses") return false;
238
+ return transport !== "websocket";
239
+ }
240
+
241
+ /** Choose and prepare the fork for a new side thread. */
242
+ export async function makeFork(env: ForkEnv, observation: Observation | undefined, why: string | undefined): Promise<Fork> {
243
+ const branchLength = env.sessionManager.getBranch().length;
244
+ if (observation?.payload) {
245
+ const reason = await prepareExact(env, observation, branchLength);
246
+ if (typeof reason !== "string") return reason;
247
+ return makeRebuilt(env, observation, reason, branchLength);
248
+ }
249
+ return makeRebuilt(env, observation, why ?? "no main request observed yet", branchLength);
250
+ }
251
+
252
+ async function prepareExact(env: ForkEnv, observation: Observation, branchLength: number): Promise<Fork | string> {
253
+ const recipe = observation.recipe;
254
+ if (!T0_APIS.has(recipe.api)) return `${recipe.api} is not checked`;
255
+ if (!isStatelessTransport(recipe.api, recipe.transport)) return "the main session uses a stateful transport";
256
+ const p = observation.payload!;
257
+ if ("previous_response_id" in p && p.previous_response_id) return "the main request continues a stored response";
258
+ const gained = gainedSince(env.sessionManager, observation.leafId);
259
+ if (!gained) return "the branch moved away from the observed request";
260
+ const registered = env.registry.find(recipe.provider, recipe.modelId);
261
+ if (!registered) return "the model is no longer registered";
262
+ let snapshot: Any;
263
+ try {
264
+ snapshot = structuredClone(registered);
265
+ } catch {
266
+ return "the model cannot be copied";
267
+ }
268
+ const fork: Fork = {
269
+ tier: "exact",
270
+ base: withPlaceholders([...observation.transcript, ...gained]),
271
+ observation,
272
+ recipe: { ...recipe },
273
+ modelSnapshot: snapshot,
274
+ branchLength,
275
+ };
276
+ let b0: Payload;
277
+ try {
278
+ b0 = await dryRun(env, fork, structuredClone(snapshot), observation.transcript);
279
+ } catch (error) {
280
+ return error instanceof Error ? error.message : String(error);
281
+ }
282
+ const delta = diffTopLevel(b0, p);
283
+ const c1 = checkC1(recipe.api, b0, p, delta);
284
+ if (!c1.ok) return c1.reason!;
285
+ fork.b0 = b0;
286
+ fork.delta = delta;
287
+ fork.pHash = await sha256(canonical(p));
288
+ // A lower effort is used only when the dry conversion proves that it changes nothing but E.
289
+ const lower = asideLevel(snapshot, recipe.reasoning);
290
+ if (lower !== recipe.reasoning) {
291
+ try {
292
+ const b0e = await dryRun(env, fork, structuredClone(snapshot), observation.transcript, lower);
293
+ if (differsOnlyInConfig(recipe.api, b0e, b0) && !deepEqual(b0e, b0)) {
294
+ fork.effort = lower;
295
+ fork.b0e = b0e;
296
+ }
297
+ } catch {
298
+ // the main session's level
299
+ }
300
+ }
301
+ return fork;
302
+ }
303
+
304
+ function canonical(value: unknown): string {
305
+ return JSON.stringify(sortKeys(value));
306
+ }
307
+
308
+ function sortKeys(value: unknown): unknown {
309
+ if (Array.isArray(value)) return value.map(sortKeys);
310
+ if (value && typeof value === "object") {
311
+ const out: Record<string, unknown> = {};
312
+ for (const key of Object.keys(value).sort()) out[key] = sortKeys((value as Record<string, unknown>)[key]);
313
+ return out;
314
+ }
315
+ return value;
316
+ }
317
+
318
+ /** The physical model of the latest response on the branch. */
319
+ function lastPhysicalModel(env: ForkEnv): { provider: string; id: string } | undefined {
320
+ const branch: Any[] = env.sessionManager.getBranch();
321
+ for (let i = branch.length - 1; i >= 0; i--) {
322
+ const m = branch[i]?.type === "message" ? branch[i].message : undefined;
323
+ if (m?.role === "assistant" && m.stopReason !== "error" && m.stopReason !== "aborted" && m.api !== "pi-virtual") {
324
+ return { provider: m.provider, id: m.model };
325
+ }
326
+ }
327
+ return undefined;
328
+ }
329
+
330
+ export function makeRebuilt(env: ForkEnv, observation: Observation | undefined, reason: string, branchLength: number): Fork {
331
+ let base: unknown[] | undefined;
332
+ let recipe: Recipe | undefined = observation?.recipe;
333
+ if (observation) {
334
+ const gained = gainedSince(env.sessionManager, observation.leafId);
335
+ if (gained) base = withPlaceholders([...observation.transcript, ...gained]);
336
+ }
337
+ if (!base) {
338
+ const context = env.sessionManager.buildSessionContext();
339
+ const system = env.systemPrompt();
340
+ base = withPlaceholders([
341
+ ...(system ? [{ role: "system", content: system, timestamp: 0 }] : []),
342
+ ...(context?.messages ?? []),
343
+ ]);
344
+ recipe = undefined;
345
+ }
346
+ let provider: string;
347
+ let modelId: string;
348
+ let api: string;
349
+ if (recipe && !recipe.virtual) {
350
+ provider = recipe.provider;
351
+ modelId = recipe.modelId;
352
+ api = recipe.api;
353
+ } else {
354
+ const physical = lastPhysicalModel(env);
355
+ const selected = env.selectedModel();
356
+ const found = physical ? env.registry.find(physical.provider, physical.id) : undefined;
357
+ const model = found ?? selected;
358
+ if (!model) throw new Error("no model is selected");
359
+ provider = model.provider;
360
+ modelId = model.id;
361
+ api = model.api;
362
+ }
363
+ const level = recipe?.reasoning ?? (env.thinkingLevel() === "off" ? undefined : env.thinkingLevel());
364
+ const settings = env.settings();
365
+ const lower = asideLevel(env.registry.find(provider, modelId), level);
366
+ return {
367
+ tier: "rebuilt",
368
+ reason,
369
+ base,
370
+ observation,
371
+ ...(lower !== level ? { effort: lower } : {}),
372
+ recipe: {
373
+ provider,
374
+ modelId,
375
+ api,
376
+ reasoning: level,
377
+ // T1 is isolated from the main session's transport state (§4.8).
378
+ sessionId: crypto.randomUUID(),
379
+ transport: "sse",
380
+ thinkingBudgets: recipe?.thinkingBudgets ?? settings.getThinkingBudgets(),
381
+ blockImages: recipe?.blockImages ?? settings.getBlockImages(),
382
+ },
383
+ branchLength,
384
+ };
385
+ }
386
+
387
+ /** Send one request of the side thread: base ++ exchange. */
388
+ export async function send(
389
+ env: ForkEnv,
390
+ fork: Fork,
391
+ exchange: unknown[],
392
+ signal: AbortSignal,
393
+ onEvent: (event: Any) => void,
394
+ ): Promise<SendResult> {
395
+ const messages = [...fork.base, ...exchange];
396
+ if (fork.tier === "exact") {
397
+ for (let attempt = 0; attempt < 3; attempt++) {
398
+ const result = await sendExact(env, fork, messages, signal, onEvent);
399
+ if (result !== "restart") return result;
400
+ }
401
+ return demote(env, fork, "the model configuration kept changing", messages, signal, onEvent);
402
+ }
403
+ return sendRebuilt(env, fork, messages, signal, onEvent);
404
+ }
405
+
406
+ async function demote(
407
+ env: ForkEnv,
408
+ fork: Fork,
409
+ reason: string,
410
+ messages: unknown[],
411
+ signal: AbortSignal,
412
+ onEvent: (event: Any) => void,
413
+ ): Promise<SendResult> {
414
+ const rebuilt = makeRebuilt(env, fork.observation, reason, fork.branchLength);
415
+ // The rebuilt fork keeps the same conversation; only the transport isolation and the label change.
416
+ fork.tier = "rebuilt";
417
+ fork.reason = reason;
418
+ fork.recipe = rebuilt.recipe;
419
+ fork.effort = rebuilt.effort;
420
+ fork.b0 = undefined;
421
+ fork.b0e = undefined;
422
+ fork.delta = undefined;
423
+ return sendRebuilt(env, fork, messages, signal, onEvent);
424
+ }
425
+
426
+ async function sendExact(
427
+ env: ForkEnv,
428
+ fork: Fork,
429
+ messages: unknown[],
430
+ signal: AbortSignal,
431
+ onEvent: (event: Any) => void,
432
+ ): Promise<SendResult | "restart"> {
433
+ const recipe = fork.recipe;
434
+ const p = fork.observation!.payload!;
435
+ let restart = false;
436
+ let fallback: string | undefined;
437
+ let sent: Payload | undefined;
438
+ // Same-tick send of a private model recipe (§4.2): look up, clone, snapshot the environment and start the
439
+ // stream in one synchronous step.
440
+ const registered = env.registry.find(recipe.provider, recipe.modelId);
441
+ if (!registered) return demote(env, fork, "the model is no longer registered", messages, signal, onEvent);
442
+ let clone: Any;
443
+ try {
444
+ clone = structuredClone(registered);
445
+ } catch {
446
+ return demote(env, fork, "the model cannot be copied", messages, signal, onEvent);
447
+ }
448
+ if (!deepEqual(clone, fork.modelSnapshot)) {
449
+ return demote(env, fork, "the model configuration changed", messages, signal, onEvent);
450
+ }
451
+ const options = baseOptions(env, fork, clone, signal);
452
+ const context = toContext(messages, recipe.blockImages);
453
+ const env0 = envSnapshot();
454
+ options.onPayload = (b1: unknown) => {
455
+ // Currency check: the registry's model and the environment are still the ones cloned.
456
+ let fresh: Any;
457
+ try {
458
+ fresh = structuredClone(env.registry.find(recipe.provider, recipe.modelId));
459
+ } catch {
460
+ fresh = undefined;
461
+ }
462
+ if (!deepEqual(fresh, clone) || !envUnchanged(env0)) {
463
+ restart = true;
464
+ throw new Fallback("restart");
465
+ }
466
+ const c2 = checkC2(recipe.api, fork.b0e ?? fork.b0!, b1 as Payload, p);
467
+ if (!c2.ok) {
468
+ fallback = c2.reason;
469
+ throw new Fallback(c2.reason);
470
+ }
471
+ const f = applyDelta(b1 as Payload, fork.delta!);
472
+ const c3 = checkC3(recipe.api, f, p, fork.b0e ? splitList(recipe.api, fork.b0e)?.config : undefined);
473
+ if (!c3.ok) {
474
+ fallback = c3.reason;
475
+ throw new Fallback(c3.reason);
476
+ }
477
+ sent = f;
478
+ return f;
479
+ };
480
+ const stream = env.registry.streamSimple(clone, context, options);
481
+ const message = await pump(stream, onEvent, () => sent !== undefined);
482
+ if (restart) return "restart";
483
+ if (fallback) return demote(env, fork, fallback, messages, signal, onEvent);
484
+ return { message, tier: "exact", markers: sent ? countMarkers(sent) : undefined, sent };
485
+ }
486
+
487
+ async function sendRebuilt(
488
+ env: ForkEnv,
489
+ fork: Fork,
490
+ messages: unknown[],
491
+ signal: AbortSignal,
492
+ onEvent: (event: Any) => void,
493
+ ): Promise<SendResult> {
494
+ const recipe = fork.recipe;
495
+ const model = env.registry.find(recipe.provider, recipe.modelId) ?? env.selectedModel();
496
+ if (!model) throw new Error("no model is available for the aside");
497
+ const options = baseOptions(env, fork, model, signal);
498
+ let sent: Payload | undefined;
499
+ options.onPayload = (payload: unknown) => {
500
+ if (payload && typeof payload === "object" && "previous_response_id" in payload) {
501
+ const copy = { ...(payload as Payload) };
502
+ delete copy.previous_response_id;
503
+ sent = copy;
504
+ return copy;
505
+ }
506
+ sent = payload as Payload;
507
+ return undefined;
508
+ };
509
+ const stream = env.registry.streamSimple(model, toContext(messages, recipe.blockImages), options);
510
+ const message = await pump(stream, onEvent, () => true);
511
+ return { message, tier: "rebuilt", reason: fork.reason, markers: sent ? countMarkers(sent) : undefined, sent };
512
+ }
513
+
514
+ /** Drain a stream, forwarding its events only once the request was accepted for sending. */
515
+ async function pump(stream: Any, onEvent: (event: Any) => void, accepted: () => boolean): Promise<Any> {
516
+ for await (const event of stream) {
517
+ if (accepted()) onEvent(event);
518
+ }
519
+ return await stream.result();
520
+ }