@junghanacs/entwurf 0.50.0 → 0.51.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/AGENTS.md +2 -1
- package/CHANGELOG.md +80 -0
- package/README.md +12 -4
- package/VERIFY.md +16 -9
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +2 -2
- package/docs/setup-clean-host.md +65 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +4 -4
- package/package.json +11 -18
- package/pi/pi-durable/carrier/runtime.js +221 -50
- package/pi/pi-durable/carrier/subagent.js +5 -2
- package/pi/pi-durable/carrier/tui.js +60 -22
- package/pi/pi-durable/overlay/upstream-pin.json +17 -16
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +73 -69
- package/pi-extensions/lib/entwurf-v2-contract.ts +4 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +1 -1
- package/run.sh +141 -42
- package/scripts/check-entwurf-v2-contract.ts +4 -4
- package/scripts/check-gate-qualification.ts +2 -2
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/pi-durable.json +189 -3
- package/scripts/mutants/pi-floor.json +22 -5
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
3
2
|
import {
|
|
4
3
|
clampThinkingLevel,
|
|
@@ -11,15 +10,22 @@ import {
|
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
|
|
13
|
+
|
|
14
14
|
|
|
15
|
+
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
Harness,
|
|
18
19
|
|
|
20
|
+
LiveDoc,
|
|
19
21
|
|
|
22
|
+
|
|
20
23
|
ROOT_CONVERSATION_ID,
|
|
21
24
|
|
|
22
25
|
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
23
29
|
} from "@earendil-works/pi-durable";
|
|
24
30
|
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
25
31
|
import { ModelRuntime } from "entwurf-pi-dist:core/model-runtime.js";
|
|
@@ -58,8 +64,19 @@ const context = BACKGROUND_CONTEXT;
|
|
|
58
64
|
/** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
|
|
59
65
|
|
|
60
66
|
|
|
61
|
-
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
62
72
|
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
63
80
|
|
|
64
81
|
|
|
65
82
|
|
|
@@ -72,6 +89,11 @@ const context = BACKGROUND_CONTEXT;
|
|
|
72
89
|
|
|
73
90
|
|
|
74
91
|
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
75
97
|
|
|
76
98
|
|
|
77
99
|
|
|
@@ -156,6 +178,32 @@ function causeOf(error ) {
|
|
|
156
178
|
return error instanceof Error ? error.message : String(error);
|
|
157
179
|
}
|
|
158
180
|
|
|
181
|
+
/** The shown conversation's active transcript and the newest entry ID in it. */
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
/** A read's active transcript and the watch attached right after the read. */
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
/** The newest entry ID of `entries`; a head marker comes first, so the last entry need not be the newest. */
|
|
194
|
+
function newestOf(entries ) {
|
|
195
|
+
return entries.reduce((newest, entry) => Math.max(newest, entry.id), Number.NEGATIVE_INFINITY);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What the TUI renders: the watched documents with the active transcript as items. */
|
|
199
|
+
function transcriptOf(view , entries ) {
|
|
200
|
+
return {
|
|
201
|
+
conversation: view.conversation,
|
|
202
|
+
docs: view.docs,
|
|
203
|
+
items: entries.map((entry) => ({ kind: "entry" , entry })),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
159
207
|
export async function openDurable(options ) {
|
|
160
208
|
// The type requires it; a JS caller is refused here, before the session root is created.
|
|
161
209
|
if (options.fence === undefined) {
|
|
@@ -187,8 +235,9 @@ export async function openDurable(options )
|
|
|
187
235
|
registry.install(Subagent);
|
|
188
236
|
for (const extension of options.extensions ?? []) registry.install(extension);
|
|
189
237
|
|
|
190
|
-
|
|
191
|
-
|
|
238
|
+
// SDK 1.5 hands one tagged `Report` (what happened by `kind`, plus its `error`), not pi 1.4's (error, info).
|
|
239
|
+
const pendingReports = [];
|
|
240
|
+
let report = (reported) => pendingReports.push(reported);
|
|
192
241
|
// Recorded BEFORE the call: once the open is attempted, no later outcome proves that nothing was opened.
|
|
193
242
|
ticket.storageOpenAttempted = true;
|
|
194
243
|
const backend = await openNodeSqliteStorage(location.database);
|
|
@@ -200,7 +249,7 @@ export async function openDurable(options )
|
|
|
200
249
|
registry,
|
|
201
250
|
settings,
|
|
202
251
|
env: envs.env,
|
|
203
|
-
onReport: (
|
|
252
|
+
onReport: (reported) => report(reported),
|
|
204
253
|
},
|
|
205
254
|
context,
|
|
206
255
|
);
|
|
@@ -245,7 +294,23 @@ export async function openDurable(options )
|
|
|
245
294
|
cursor = page.next;
|
|
246
295
|
} while (cursor !== undefined);
|
|
247
296
|
let current = root;
|
|
248
|
-
|
|
297
|
+
// The shown conversation's transcript is pi 1.4's active one (#155 D1). One official read gives the active range
|
|
298
|
+
// (`Conversation.context().entries`); the watch attached right after it starts at the next entry ID, so what is
|
|
299
|
+
// committed between the two is its first value. Nothing here derives an active range: when one changes, it is
|
|
300
|
+
// read again.
|
|
301
|
+
const seed = async (conversation ) => {
|
|
302
|
+
const read = await conversation.context(context);
|
|
303
|
+
const newest = newestOf(read.entries);
|
|
304
|
+
const attached = await conversation.watch(
|
|
305
|
+
context,
|
|
306
|
+
Number.isFinite(newest) ? { from: (newest + 1) } : undefined,
|
|
307
|
+
);
|
|
308
|
+
return { watch: attached, shown: { entries: read.entries, newest } };
|
|
309
|
+
};
|
|
310
|
+
const first = await seed(root);
|
|
311
|
+
// The shown watch: the first one until `install` makes it so, then each one a switch or a new read installs.
|
|
312
|
+
let watch ;
|
|
313
|
+
let shown = first.shown;
|
|
249
314
|
const models = () =>
|
|
250
315
|
modelRuntime.getAvailableSnapshot().map((model) => ({
|
|
251
316
|
provider: model.provider,
|
|
@@ -256,7 +321,8 @@ export async function openDurable(options )
|
|
|
256
321
|
|
|
257
322
|
let state = {
|
|
258
323
|
session: { id: location.id, directory: location.directory, cwd: location.cwd },
|
|
259
|
-
conversation:
|
|
324
|
+
conversation: first.watch.value,
|
|
325
|
+
transcript: transcriptOf(first.watch.value, shown.entries),
|
|
260
326
|
conversations: summaries,
|
|
261
327
|
models: models(),
|
|
262
328
|
notices: [],
|
|
@@ -279,13 +345,16 @@ export async function openDurable(options )
|
|
|
279
345
|
update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
|
|
280
346
|
};
|
|
281
347
|
const fail = (error ) => notice("error", error instanceof Error ? error.message : String(error));
|
|
282
|
-
report = (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
348
|
+
report = (reported) => {
|
|
349
|
+
// The error the SDK reported, never the Report itself: that would read `[object Object]`.
|
|
350
|
+
const message = causeOf(reported.error);
|
|
351
|
+
// A failing Session runs its close listeners first and reports the cause right after, as `storage_failed`:
|
|
352
|
+
// that report names the failure the close listener recorded.
|
|
353
|
+
if (reported.kind === "storage_failed" && state.failure !== undefined && state.failure.error === undefined) {
|
|
354
|
+
update({ failure: { error: message } });
|
|
355
|
+
} else notice(reported.kind === "storage_failed" ? "error" : "warning", `${reported.kind}: ${message}`);
|
|
287
356
|
};
|
|
288
|
-
for (const
|
|
357
|
+
for (const reported of pendingReports) report(reported);
|
|
289
358
|
const failedListeners = new Set ();
|
|
290
359
|
// The Session closes itself when its Storage fails. Its `closed` settles only after every running task returned,
|
|
291
360
|
// which a task ignoring its abort can delay without end, so the failure is shown from the close listener itself.
|
|
@@ -300,29 +369,124 @@ export async function openDurable(options )
|
|
|
300
369
|
}
|
|
301
370
|
}
|
|
302
371
|
});
|
|
303
|
-
let
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
372
|
+
let closing ;
|
|
373
|
+
// A watch that ends by itself while the Session runs leaves its part of the screen frozen: say so rather than look
|
|
374
|
+
// healthy (#155). Ending at close, at a failure the Session already shows, or by being replaced, is no news.
|
|
375
|
+
const ended = (part , end ) => {
|
|
376
|
+
if (closing !== undefined || state.failure !== undefined) return;
|
|
377
|
+
if (end.kind !== "failed" && end.kind !== "listener_error") return;
|
|
378
|
+
update({ stalled: { ...state.stalled, [part]: causeOf(end.error) } });
|
|
379
|
+
};
|
|
380
|
+
// The watch whose entries wait: its conversation is being read again, or that read failed. A failed read holds
|
|
381
|
+
// the transcript where it was, said on screen, until a switch installs a new watch; there is no retry.
|
|
382
|
+
let held ;
|
|
383
|
+
// The shown conversation read again, one read at a time. It is installed only while `trigger` is still the shown
|
|
384
|
+
// watch: a switch made meanwhile wins, and the late read is dropped (#155 D1).
|
|
385
|
+
const reseed = (trigger ) => {
|
|
386
|
+
held = trigger;
|
|
387
|
+
const conversation = current;
|
|
388
|
+
void seed(conversation).then(
|
|
389
|
+
(next) => {
|
|
390
|
+
if (watch !== trigger) {
|
|
391
|
+
void next.watch.stop();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
install(conversation, next);
|
|
395
|
+
},
|
|
396
|
+
(error) => {
|
|
397
|
+
// A read refused by the close or the Session's own failure is no news, as `ended` has it.
|
|
398
|
+
if (watch !== trigger || closing !== undefined || state.failure !== undefined) return;
|
|
399
|
+
update({ stalled: { ...state.stalled, conversation: causeOf(error) } });
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
// One value of the shown watch. Entries past the newest one shown join the transcript in ID order. A head marker
|
|
404
|
+
// among them (a compaction or a reset) or a window starting past the newest one shown (it dropped entries the
|
|
405
|
+
// transcript lacks) is a new active range: read again, nothing appended until it lands.
|
|
406
|
+
const advance = (view ) => {
|
|
407
|
+
if (held !== undefined && held === watch) {
|
|
408
|
+
update({ conversation: view });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const fresh = view.entries.filter((entry) => entry.id > shown.newest);
|
|
412
|
+
const restarts = fresh.some((entry) => entry.head !== undefined);
|
|
413
|
+
const skipped = view.before !== undefined && view.before > shown.newest;
|
|
414
|
+
if (restarts || skipped) {
|
|
415
|
+
update({ conversation: view });
|
|
416
|
+
reseed(watch);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// A value without new entries (a document change, such as a streaming answer's progress) leaves the
|
|
420
|
+
// transcript as it is.
|
|
421
|
+
if (fresh.length === 0) {
|
|
422
|
+
update({ conversation: view });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
shown = { entries: [...shown.entries, ...fresh], newest: newestOf(fresh) };
|
|
426
|
+
update({ conversation: view, transcript: transcriptOf(view, shown.entries) });
|
|
427
|
+
};
|
|
428
|
+
// Every later value of the shown watch; a replaced watch's late frames are dropped.
|
|
429
|
+
const follow = (handle ) => {
|
|
430
|
+
handle.start(async (call) => {
|
|
431
|
+
if (handle !== watch) return;
|
|
432
|
+
if (call.kind === "failed") {
|
|
433
|
+
notice("warning", `The conversation view could not update: ${causeOf(call.error)}`);
|
|
434
|
+
return;
|
|
315
435
|
}
|
|
436
|
+
advance(call.value);
|
|
437
|
+
});
|
|
438
|
+
void handle.closed.then((end) => {
|
|
439
|
+
if (handle === watch) ended("conversation", end);
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
// Show `conversation` from a read: its active entries, then the watch's first value, which holds what was
|
|
443
|
+
// committed between the read and the attach and is taken exactly once here, then the watch's later values.
|
|
444
|
+
const install = (conversation , next ) => {
|
|
445
|
+
// The close and the Session's own failure end what is shown, as a switch does: a read landing after either
|
|
446
|
+
// is let go, its watch stopped (the Session may already have ended it), and nothing is patched or started.
|
|
447
|
+
if (closing !== undefined || state.failure !== undefined) {
|
|
448
|
+
void next.watch.stop();
|
|
449
|
+
return;
|
|
316
450
|
}
|
|
317
|
-
|
|
451
|
+
const previous = watch;
|
|
452
|
+
current = conversation;
|
|
453
|
+
watch = next.watch;
|
|
454
|
+
shown = next.shown;
|
|
455
|
+
void previous?.stop();
|
|
456
|
+
// A new watch replaces a stalled one; the task graph's state is its own.
|
|
457
|
+
const stalled = state.stalled?.tasks === undefined ? undefined : { tasks: state.stalled.tasks };
|
|
458
|
+
update({ conversation: next.watch.value, transcript: transcriptOf(next.watch.value, shown.entries), stalled });
|
|
459
|
+
advance(next.watch.value);
|
|
460
|
+
follow(next.watch);
|
|
461
|
+
};
|
|
462
|
+
install(root, first);
|
|
463
|
+
// Subagents appear as their conversations are created. A commit observer only records; it calls no Session API.
|
|
464
|
+
// Its end decides nothing: SDK 1.5 ends it `failed` ("session closed") at a normal close as well (#155).
|
|
465
|
+
const commits = harness.observeCommits({
|
|
466
|
+
advance: (publication) => {
|
|
467
|
+
let conversations = state.conversations;
|
|
468
|
+
for (const change of publication.changes) {
|
|
469
|
+
if (change.type === "conversation") {
|
|
470
|
+
conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
|
|
471
|
+
} else if (change.type === "entry" && change.value.kind === "pi.user") {
|
|
472
|
+
const id = change.value.conversationId;
|
|
473
|
+
conversations = conversations.map((summary) =>
|
|
474
|
+
summary.id === id && summary.title === undefined
|
|
475
|
+
? { ...summary, ...titleOf(change.value) }
|
|
476
|
+
: summary,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (conversations !== state.conversations) update({ conversations });
|
|
481
|
+
},
|
|
482
|
+
end: () => {},
|
|
318
483
|
});
|
|
319
484
|
|
|
320
|
-
let tasks
|
|
321
|
-
let unsubscribeTasks = () => {};
|
|
485
|
+
let tasks ;
|
|
322
486
|
const closeTasks = () => {
|
|
323
|
-
|
|
324
|
-
tasks?.dispose();
|
|
487
|
+
const graph = tasks;
|
|
325
488
|
tasks = undefined;
|
|
489
|
+
void graph?.stop();
|
|
326
490
|
};
|
|
327
491
|
|
|
328
492
|
let queue = Promise.resolve();
|
|
@@ -331,15 +495,21 @@ export async function openDurable(options )
|
|
|
331
495
|
queue = queue.then(operation).catch(fail);
|
|
332
496
|
return queue;
|
|
333
497
|
};
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
498
|
+
// A run that ended without an answer stays on screen as its conversation's `pi.live.runFailure` (SDK 1.5), so an
|
|
499
|
+
// input that run settled adds no notice of its own; one settled without such a run (stale, say) still does.
|
|
500
|
+
const watchAnswer = (submission , conversation ) => {
|
|
501
|
+
void submission
|
|
502
|
+
.wait(context)
|
|
503
|
+
.then(async (settled) => {
|
|
504
|
+
if (settled.status !== "unanswered" || settled.reason === "aborted") return;
|
|
505
|
+
const live = await opened.snapshot(LiveDoc, conversation.id, context);
|
|
506
|
+
if (live?.runFailure?.inputs.includes(submission.id)) return;
|
|
337
507
|
notice(
|
|
338
508
|
"error",
|
|
339
509
|
`No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
|
|
340
510
|
);
|
|
341
|
-
}
|
|
342
|
-
|
|
511
|
+
})
|
|
512
|
+
.catch(fail);
|
|
343
513
|
};
|
|
344
514
|
const agentModel = () => {
|
|
345
515
|
const ref = agentOf(state.conversation).model;
|
|
@@ -354,7 +524,8 @@ export async function openDurable(options )
|
|
|
354
524
|
if (state.failure !== undefined) {
|
|
355
525
|
throw new Error("The session failed and takes no input. Close it, then reopen it with --continue.");
|
|
356
526
|
}
|
|
357
|
-
|
|
527
|
+
const target = current;
|
|
528
|
+
watchAnswer(await target.submit({ type: "input", content: text, whenBusy }, context), target);
|
|
358
529
|
}),
|
|
359
530
|
compact: (instructions) =>
|
|
360
531
|
command(async () => {
|
|
@@ -408,12 +579,8 @@ export async function openDurable(options )
|
|
|
408
579
|
command(async () => {
|
|
409
580
|
const next = await opened.conversation(id, context);
|
|
410
581
|
if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
conversation.dispose();
|
|
414
|
-
current = next;
|
|
415
|
-
conversation = nextState;
|
|
416
|
-
unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
|
|
582
|
+
// The same read as the open: the whole active transcript, whatever this process showed of it before.
|
|
583
|
+
install(next, await seed(next));
|
|
417
584
|
}),
|
|
418
585
|
};
|
|
419
586
|
|
|
@@ -424,14 +591,17 @@ export async function openDurable(options )
|
|
|
424
591
|
}
|
|
425
592
|
if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
|
|
426
593
|
// The task graph is observed until close; its panel starts open and /tasks hides it.
|
|
427
|
-
const graph = await opened.
|
|
594
|
+
const graph = await opened.watchTaskGraph(context);
|
|
428
595
|
tasks = graph;
|
|
429
596
|
update({ tasks: graph.value });
|
|
430
|
-
|
|
597
|
+
graph.start(async (call) => {
|
|
598
|
+
if (call.kind === "value") update({ tasks: call.value });
|
|
599
|
+
else notice("warning", `The task graph could not update: ${causeOf(call.error)}`);
|
|
600
|
+
});
|
|
601
|
+
void graph.closed.then((end) => ended("tasks", end));
|
|
431
602
|
// Recovered work from an interrupted turn continues now.
|
|
432
603
|
harness.resume();
|
|
433
604
|
|
|
434
|
-
let closing ;
|
|
435
605
|
return {
|
|
436
606
|
view: {
|
|
437
607
|
current: () => state,
|
|
@@ -460,9 +630,8 @@ export async function openDurable(options )
|
|
|
460
630
|
closing ??= (async () => {
|
|
461
631
|
// First: the Session runs its close listeners inside its own close call.
|
|
462
632
|
unsubscribeClose();
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
conversation.dispose();
|
|
633
|
+
void watch?.stop();
|
|
634
|
+
commits.detach();
|
|
466
635
|
closeTasks();
|
|
467
636
|
// The resource end the fence records (#157 B3). Storage is `closed` only when the first actual close
|
|
468
637
|
// resolved, no failure was seen before it, and the Session's own terminal result is not `failed`.
|
|
@@ -473,8 +642,10 @@ export async function openDurable(options )
|
|
|
473
642
|
try {
|
|
474
643
|
// Close writes no outcome: a running turn resumes with --continue.
|
|
475
644
|
await opened.close(context);
|
|
476
|
-
|
|
477
|
-
|
|
645
|
+
// SDK 1.5 tells the end by `kind`; pi 1.4's `reason` is absent there, and a type-stripped carrier would
|
|
646
|
+
// read it as undefined and call every end closed (#155).
|
|
647
|
+
const end = await opened.closed;
|
|
648
|
+
if (state.failure === undefined && end.kind !== "failed") storage = "closed";
|
|
478
649
|
try {
|
|
479
650
|
await envs.cleanup(context);
|
|
480
651
|
} catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import {
|
|
4
4
|
AssistantEntry,
|
|
5
5
|
configure,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
} from "@earendil-works/pi-durable";
|
|
13
|
+
import { obj, str } from "@earendil-works/pi-schema";
|
|
13
14
|
|
|
14
15
|
async function answerText(api , answer , context ) {
|
|
15
16
|
const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context);
|
|
@@ -29,7 +30,9 @@ export const Subagent = defineExtension({
|
|
|
29
30
|
name: "subagent",
|
|
30
31
|
description:
|
|
31
32
|
"Delegate a self-contained task to a subagent with the same tools and get its answer back. Give it everything it needs to know; it does not see this conversation.",
|
|
32
|
-
|
|
33
|
+
// pi-ai 1.5 no longer exports TypeBox's `Type`; pi-schema builds the same JSON Schema. `open` keeps the input
|
|
34
|
+
// as open as the TypeBox object was, since `obj()` alone closes it (SDK 1.5's own tools do the same, #155).
|
|
35
|
+
parameters: obj({ task: str({ description: "What the subagent should do" }) }, { open: true }),
|
|
33
36
|
// A rerun after a crash finds the child it created and the submission it made.
|
|
34
37
|
replay: "safe",
|
|
35
38
|
execute: async (args, api, context) => {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -176,8 +177,9 @@ class CompactionComponent extends Box {
|
|
|
176
177
|
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
|
-
* The lines above the editor: first what stays true for as long as it is true, the session's failure
|
|
180
|
-
* compaction failure a conversation keeps until a compaction produces a summary,
|
|
180
|
+
* The lines above the editor: first what stays true for as long as it is true, the session's failure, the
|
|
181
|
+
* compaction failure a conversation keeps until a compaction produces a summary, the run that ended without an
|
|
182
|
+
* answer until another run starts, and a view that stopped updating; then the latest notices.
|
|
181
183
|
*/
|
|
182
184
|
export function durableNotices(view ) {
|
|
183
185
|
const lines = [];
|
|
@@ -188,9 +190,22 @@ export function durableNotices(view )
|
|
|
188
190
|
message: `Session failed${cause}. It takes no more input, and a tool still running may take a while to stop. Close this session, then reopen it with --continue.`,
|
|
189
191
|
});
|
|
190
192
|
}
|
|
191
|
-
const
|
|
193
|
+
const live = (view.conversation.docs["pi.live"] ?? {}) ;
|
|
194
|
+
const compaction = live.compactionFailure;
|
|
192
195
|
if (compaction !== undefined) {
|
|
193
|
-
lines.push({ level: "error", message: `Compaction failed (${compaction.
|
|
196
|
+
lines.push({ level: "error", message: `Compaction failed (${compaction.trigger}): ${compaction.error}` });
|
|
197
|
+
}
|
|
198
|
+
// SDK 1.5 keeps why the newest run ended without an answer. An aborted run is the user's own stop, as before:
|
|
199
|
+
// the answer it cut off already says so.
|
|
200
|
+
const run = live.runFailure;
|
|
201
|
+
if (run !== undefined && run.reason !== "aborted") {
|
|
202
|
+
const detail = run.detail === undefined ? "" : ` (${run.detail})`;
|
|
203
|
+
lines.push({ level: "error", message: `No answer: ${run.reason}${detail}` });
|
|
204
|
+
}
|
|
205
|
+
for (const [part, error] of Object.entries(view.stalled ?? {})) {
|
|
206
|
+
if (error === undefined) continue;
|
|
207
|
+
const what = part === "tasks" ? "The task panel" : "The conversation";
|
|
208
|
+
lines.push({ level: "error", message: `${what} stopped updating: ${error}` });
|
|
194
209
|
}
|
|
195
210
|
return [...lines, ...view.notices.slice(-4)];
|
|
196
211
|
}
|
|
@@ -289,7 +304,8 @@ class DurableTui {
|
|
|
289
304
|
#summaries = [];
|
|
290
305
|
/** Tool output and summaries shown in full; toggled like pi. */
|
|
291
306
|
#expanded = false;
|
|
292
|
-
|
|
307
|
+
/** One key per transcript item on screen, in order (`keyOf`). */
|
|
308
|
+
#renderedKeys = [];
|
|
293
309
|
#streaming ;
|
|
294
310
|
#indicator ;
|
|
295
311
|
#statusText = "";
|
|
@@ -391,10 +407,11 @@ class DurableTui {
|
|
|
391
407
|
|
|
392
408
|
apply(view ) {
|
|
393
409
|
const live = (view.conversation.docs["pi.live"] ?? {}) ;
|
|
394
|
-
|
|
410
|
+
// The active transcript, not the window: every entry of the active range, head marker first (#155 D1).
|
|
411
|
+
this.#syncTranscript(view.transcript.items);
|
|
395
412
|
const message = live.generation?.message ;
|
|
396
413
|
// A partial without its entry was dropped, for example by a retry: render the transcript again.
|
|
397
|
-
if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.
|
|
414
|
+
if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.transcript.items);
|
|
398
415
|
if (message !== undefined) this.#syncStreaming(message);
|
|
399
416
|
for (const slot of live.tools ?? []) {
|
|
400
417
|
if (slot.status === "pending") continue;
|
|
@@ -476,8 +493,8 @@ class DurableTui {
|
|
|
476
493
|
} else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
|
|
477
494
|
else if (compaction !== undefined) {
|
|
478
495
|
text = compaction.retry
|
|
479
|
-
? `Retrying ${compaction.
|
|
480
|
-
: `Compacting (${compaction.
|
|
496
|
+
? `Retrying ${compaction.trigger} compaction (attempt ${compaction.attempt + 1})...`
|
|
497
|
+
: `Compacting (${compaction.trigger})...`;
|
|
481
498
|
} else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
|
|
482
499
|
else if (live.run !== undefined) text = "Working... (esc to abort)";
|
|
483
500
|
if (text === this.#statusText) return;
|
|
@@ -502,7 +519,7 @@ class DurableTui {
|
|
|
502
519
|
view.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model.modelId)
|
|
503
520
|
?.contextWindow ?? 0;
|
|
504
521
|
if (contextWindow > 0) {
|
|
505
|
-
const tokens = contextTokens(view.
|
|
522
|
+
const tokens = contextTokens(entriesOf(view.transcript.items));
|
|
506
523
|
const percent = tokens === undefined ? undefined : (tokens / contextWindow) * 100;
|
|
507
524
|
const text = `${percent === undefined ? "?" : percent.toFixed(1)}%/${formatTokens(contextWindow)}`;
|
|
508
525
|
stats.push(percent !== undefined && percent > 90 ? theme.fg("error", text) : text);
|
|
@@ -519,28 +536,38 @@ class DurableTui {
|
|
|
519
536
|
);
|
|
520
537
|
}
|
|
521
538
|
|
|
522
|
-
#syncTranscript(
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
539
|
+
#syncTranscript(items ) {
|
|
540
|
+
// The transcript grows at its end. A compaction or reset starts a new active range, its head marker first,
|
|
541
|
+
// which replaces what came before (#155 D1).
|
|
542
|
+
const keys = items.map(keyOf);
|
|
543
|
+
if (this.#renderedKeys.some((key, index) => keys[index] !== key)) this.#rebuild(items);
|
|
544
|
+
for (const item of items.slice(this.#renderedKeys.length)) {
|
|
545
|
+
this.#addItem(item);
|
|
546
|
+
this.#renderedKeys.push(keyOf(item));
|
|
528
547
|
}
|
|
529
548
|
}
|
|
530
549
|
|
|
531
|
-
#rebuild(
|
|
550
|
+
#rebuild(items ) {
|
|
532
551
|
this.#chat.clear();
|
|
533
552
|
this.#discardTools();
|
|
534
553
|
this.#summaries.length = 0;
|
|
535
554
|
this.#rebuilt = true;
|
|
536
|
-
this.#
|
|
555
|
+
this.#renderedKeys = [];
|
|
537
556
|
this.#streaming = undefined;
|
|
538
|
-
for (const
|
|
539
|
-
this.#
|
|
540
|
-
this.#
|
|
557
|
+
for (const item of items) {
|
|
558
|
+
this.#addItem(item);
|
|
559
|
+
this.#renderedKeys.push(keyOf(item));
|
|
541
560
|
}
|
|
542
561
|
}
|
|
543
562
|
|
|
563
|
+
#addItem(item ) {
|
|
564
|
+
if (item.kind === "entry") this.#addEntry(item.entry);
|
|
565
|
+
// The runtime's active transcript holds entries only; these two placeholders are the SDK item type's other kinds
|
|
566
|
+
// (#156 owns any load-more input).
|
|
567
|
+
else if (item.kind === "earlier") this.#addText("[earlier entries not shown]");
|
|
568
|
+
else this.#addText("[missing entries, loading...]");
|
|
569
|
+
}
|
|
570
|
+
|
|
544
571
|
#addEntry(entry ) {
|
|
545
572
|
const message = entry.model?.[0];
|
|
546
573
|
if (entry.kind === "pi.user" && message?.role === "user") {
|
|
@@ -676,9 +703,20 @@ function totalUsage(state ) {
|
|
|
676
703
|
return total;
|
|
677
704
|
}
|
|
678
705
|
|
|
706
|
+
/** The entries of transcript items, in ID order. */
|
|
707
|
+
function entriesOf(items ) {
|
|
708
|
+
return items.flatMap((item) => (item.kind === "entry" ? [item.entry] : []));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** What was rendered for an item stays valid while the same key stays in the same place. */
|
|
712
|
+
function keyOf(item ) {
|
|
713
|
+
if (item.kind === "entry") return `entry:${item.entry.id}`;
|
|
714
|
+
return item.kind === "gap" ? `gap:${item.after}:${item.upTo}` : "earlier";
|
|
715
|
+
}
|
|
716
|
+
|
|
679
717
|
/** Context size from the newest successful answer after the newest compaction; unknown before one. */
|
|
680
718
|
function contextTokens(entries ) {
|
|
681
|
-
//
|
|
719
|
+
// Entries a summary keeps and those it replaced are older than it; only later answers measure the new context.
|
|
682
720
|
const compacted = Math.max(0, ...entries.filter((entry) => entry.kind === "pi.compaction").map((entry) => entry.id));
|
|
683
721
|
for (const entry of [...entries].reverse()) {
|
|
684
722
|
const message = entry.model?.[0];
|
|
@@ -70,14 +70,14 @@
|
|
|
70
70
|
{
|
|
71
71
|
"name": "runtime.js",
|
|
72
72
|
"source": "pi/pi-durable/src/experimental/durable/runtime.ts",
|
|
73
|
-
"blob": "
|
|
74
|
-
"sha256": "
|
|
73
|
+
"blob": "00646b1b1fbd7e935fb3e66b7578a2befc01f3c8",
|
|
74
|
+
"sha256": "b47113364b7525abd3bf9286ecb0069f78ac96688b8a271bdbca3a54b9d864c5"
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"name": "tui.js",
|
|
78
78
|
"source": "pi/pi-durable/src/experimental/durable/tui.ts",
|
|
79
|
-
"blob": "
|
|
80
|
-
"sha256": "
|
|
79
|
+
"blob": "4b5dd1876ceac8f51910bb11ed5f0024003f4f34",
|
|
80
|
+
"sha256": "7fefe490d9d462e6a91621901b82b767546926f4a9f4e88d585fe74b727a02c1"
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"name": "harness-setup.js",
|
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
{
|
|
101
101
|
"name": "subagent.js",
|
|
102
102
|
"source": "pi/pi-durable/src/experimental/durable/subagent.ts",
|
|
103
|
-
"blob": "
|
|
104
|
-
"sha256": "
|
|
103
|
+
"blob": "e842d8a6b8dc55825d0381d803b6d6496b205c19",
|
|
104
|
+
"sha256": "c9931fabd60f5fbf75220e896a57e5c8b131b4936887bf6f9660d50d01709f3a"
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
"name": "session-name.js",
|
|
@@ -138,17 +138,18 @@
|
|
|
138
138
|
]
|
|
139
139
|
},
|
|
140
140
|
"sdk": {
|
|
141
|
-
"comment": "The published library set the carrier runs against, derived from the 1.
|
|
141
|
+
"comment": "The published library set the carrier runs against, derived from the 1.5.0 declarations. members: the @earendil-works packages the carrier imports and every @earendil-works package those declare (1.5.0 adds pi-schema, declared by chord, pi-agent-core, pi-ai and pi-durable). direct: a third-party package a carrier file imports itself (sessions.ts: proper-lockfile, at the version pi-coding-agent 1.5.0 pins). Each is an exact production dependency in package.json. Since 1.3.0 the members' own edges are exact too (upstream #10865), and the installed physical edges are still what must equal these versions.",
|
|
142
142
|
"members": {
|
|
143
|
-
"@earendil-works/chord": "1.
|
|
144
|
-
"@earendil-works/pi-agent-core": "1.
|
|
145
|
-
"@earendil-works/pi-ai": "1.
|
|
146
|
-
"@earendil-works/pi-codemode": "1.
|
|
147
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
148
|
-
"@earendil-works/pi-durable": "1.
|
|
149
|
-
"@earendil-works/pi-mcp": "1.
|
|
150
|
-
"@earendil-works/pi-
|
|
151
|
-
"@earendil-works/pi-
|
|
143
|
+
"@earendil-works/chord": "1.5.0",
|
|
144
|
+
"@earendil-works/pi-agent-core": "1.5.0",
|
|
145
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
146
|
+
"@earendil-works/pi-codemode": "1.5.0",
|
|
147
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
148
|
+
"@earendil-works/pi-durable": "1.5.0",
|
|
149
|
+
"@earendil-works/pi-mcp": "1.5.0",
|
|
150
|
+
"@earendil-works/pi-schema": "1.5.0",
|
|
151
|
+
"@earendil-works/pi-telemetry": "1.5.0",
|
|
152
|
+
"@earendil-works/pi-tui": "1.5.0"
|
|
152
153
|
},
|
|
153
154
|
"direct": {
|
|
154
155
|
"proper-lockfile": "4.1.2"
|