@mono-agent/agent-runtime 0.20.14 → 0.21.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/ARCHITECTURE.md +50 -11
- package/MIGRATION.md +30 -7
- package/README.md +219 -35
- package/package.json +9 -4
- package/src/agent/tool-bloat.js +145 -9
- package/src/agent/tools/agent-tool.js +104 -5
- package/src/agent/tools/bash.js +10 -2
- package/src/agent/tools/codex-subscription-search.js +122 -28
- package/src/agent/tools/exec.js +10 -2
- package/src/agent/tools/monitor.js +11 -2
- package/src/agent/tools/pi-bridge.js +33 -14
- package/src/agent/tools/shared/monitors.js +22 -3
- package/src/agent/tools/shared/path-resolver.js +25 -6
- package/src/agent/tools/shared/process-jobs.js +6 -1
- package/src/agent/tools/shared/process-runner.js +3 -1
- package/src/agent/tools/shared/tool-context.js +8 -0
- package/src/agent/tools/web-access-interstitial.js +70 -0
- package/src/agent/tools/web-browser-render.js +83 -58
- package/src/agent/tools/web-controller.js +112 -21
- package/src/agent/tools/web-document-extractor.js +379 -0
- package/src/agent/tools/web-fetch.js +271 -243
- package/src/agent/tools/web-request.js +65 -0
- package/src/agent/tools/web-search-output.js +165 -0
- package/src/agent/tools/web-search-state.js +75 -0
- package/src/agent/tools/web-search.js +532 -71
- package/src/ai/failure.js +3 -3
- package/src/ai/index.js +1 -0
- package/src/ai/observer.js +8 -0
- package/src/ai/pi-interop.js +156 -0
- package/src/ai/provider-check.js +131 -0
- package/src/ai/providers/pi-native/compaction-driver.js +45 -21
- package/src/ai/providers/pi-native/compaction-summary.js +140 -0
- package/src/ai/providers/pi-native/harness-adapter.js +40 -2
- package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
- package/src/ai/providers/pi-native/provider-attribution.js +102 -0
- package/src/ai/providers/pi-native/result-builder.js +28 -4
- package/src/ai/providers/pi-native/session-lifecycle.js +167 -24
- package/src/ai/providers/pi-native/stream-subscriber.js +30 -2
- package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
- package/src/ai/providers/pi-native/turn-runner.js +245 -13
- package/src/ai/providers/pi-native.js +159 -40
- package/src/ai/runtime/live-input-events.js +250 -54
- package/src/ai/runtime/router.js +30 -11
- package/src/ai/tool-lifecycle.js +32 -18
- package/src/ai/types.js +26 -5
- package/src/runtime.js +24 -5
- package/types/agent/tool-bloat.d.ts +1 -1
- package/types/agent/tools/agent-tool.d.ts +4 -1
- package/types/agent/tools/bash.d.ts +5 -3
- package/types/agent/tools/codex-subscription-search.d.ts +6 -2
- package/types/agent/tools/exec.d.ts +5 -3
- package/types/agent/tools/monitor.d.ts +5 -2
- package/types/agent/tools/pi-bridge.d.ts +6 -4
- package/types/agent/tools/shared/monitors.d.ts +17 -2
- package/types/agent/tools/shared/process-jobs.d.ts +5 -1
- package/types/agent/tools/shared/process-runner.d.ts +3 -2
- package/types/agent/tools/shared/tool-context.d.ts +2 -0
- package/types/agent/tools/web-access-interstitial.d.ts +23 -0
- package/types/agent/tools/web-browser-render.d.ts +4 -1
- package/types/agent/tools/web-controller.d.ts +4 -2
- package/types/agent/tools/web-document-extractor.d.ts +27 -0
- package/types/agent/tools/web-fetch.d.ts +19 -24
- package/types/agent/tools/web-request.d.ts +20 -0
- package/types/agent/tools/web-search-output.d.ts +31 -0
- package/types/agent/tools/web-search-state.d.ts +21 -0
- package/types/agent/tools/web-search.d.ts +10 -45
- package/types/ai/index.d.ts +1 -0
- package/types/ai/observer.d.ts +6 -0
- package/types/ai/pi-interop.d.ts +61 -0
- package/types/ai/provider-check.d.ts +53 -0
- package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
- package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
- package/types/ai/providers/pi-native/harness-adapter.d.ts +3 -1
- package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
- package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
- package/types/ai/providers/pi-native/result-builder.d.ts +11 -1
- package/types/ai/providers/pi-native/session-lifecycle.d.ts +23 -5
- package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +36 -5
- package/types/ai/runtime/live-input-events.d.ts +32 -8
- package/types/ai/tool-lifecycle.d.ts +4 -3
- package/types/ai/types.d.ts +140 -12
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
// createSessionLiveness primitives so the await-free spans are enforced by
|
|
11
11
|
// construction rather than by inline sequencing.
|
|
12
12
|
|
|
13
|
-
import { JsonlSessionRepo, MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
|
13
|
+
import { JsonlSessionRepo, MemorySessionRepo, laneConfig, laneState, operationMeta, operationResult, operationState } from "@earendil-works/pi-agent-core";
|
|
14
|
+
import { validRecoveryProjection } from "./terminal-recovery.js";
|
|
14
15
|
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { access, open, readdir, unlink } from "node:fs/promises";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
19
|
import { createSessionRegistry } from "../../runtime/sessions.js";
|
|
18
20
|
import { createSessionLiveness } from "../../runtime/session-liveness.js";
|
|
19
|
-
import { createPiSessionAdapter, PI_CONTEXT } from "./harness-adapter.js";
|
|
21
|
+
import { buildPiSessionContext, createPiSessionAdapter, PI_CONTEXT } from "./harness-adapter.js";
|
|
20
22
|
|
|
21
23
|
async function syncPath(path) {
|
|
22
24
|
const handle = await open(path, "r");
|
|
@@ -28,6 +30,7 @@ async function syncPath(path) {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
async function syncDurableTranscript(entry) {
|
|
33
|
+
if (entry.recoveryPending) throw new Error("Pi terminal recovery is pending");
|
|
31
34
|
if (!entry.durable) return;
|
|
32
35
|
const path = entry.metadata?.path;
|
|
33
36
|
if (typeof path !== "string" || !path) {
|
|
@@ -91,7 +94,7 @@ async function closeAndDeleteSession(session, repo, knownMetadata) {
|
|
|
91
94
|
// reach over native pi sessions that the legacy bridge had.
|
|
92
95
|
const nativeSessionRepo = new MemorySessionRepo();
|
|
93
96
|
const nativeSessions = createSessionRegistry({
|
|
94
|
-
isBusy: (entry) => entry.busy === true,
|
|
97
|
+
isBusy: (entry) => entry.busy === true || entry.recoveryPending === true,
|
|
95
98
|
onSync: syncDurableTranscript,
|
|
96
99
|
onEvict: async (entry, reason) => {
|
|
97
100
|
// Ordinary disposal/TTL only drops registry metadata so durable sessions
|
|
@@ -128,11 +131,13 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
/**
|
|
131
|
-
*
|
|
132
|
-
* This is intentionally stronger than live-session invalidation:
|
|
133
|
-
* rotation and retention can retire an epoch after its registry entry
|
|
134
|
-
* already evicted or after a process restart.
|
|
135
|
-
*
|
|
134
|
+
* Retire every currently materialized durable Pi transcript with this exact
|
|
135
|
+
* logical id. This is intentionally stronger than live-session invalidation:
|
|
136
|
+
* history rotation and retention can retire an epoch after its registry entry
|
|
137
|
+
* was already evicted or after a process restart. When an active old run later
|
|
138
|
+
* recreates its pathname, its post-runtime caller retries this operation. The
|
|
139
|
+
* canonical epoch has already rotated, so that old id is never resumable in the
|
|
140
|
+
* interim. Absence is success; cleanup or verification uncertainty rejects.
|
|
136
141
|
*/
|
|
137
142
|
export async function retireDurableNativeSession(providerSessionId, piSessionsRoot) {
|
|
138
143
|
if (!isSafeSessionId(providerSessionId)) {
|
|
@@ -142,7 +147,15 @@ export async function retireDurableNativeSession(providerSessionId, piSessionsRo
|
|
|
142
147
|
throw new TypeError("piSessionsRoot must be a non-empty path");
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
// First guarantee this process cannot
|
|
150
|
+
// First guarantee this process cannot resume through a stale registry entry.
|
|
151
|
+
// A host cancellation can rotate canonical history while the provider is
|
|
152
|
+
// still unwinding an open Pi session. The Pi repo deliberately refuses to
|
|
153
|
+
// delete an open session, so detach the registry and unlink its current file.
|
|
154
|
+
// Pi appends by pathname and can recreate a headerless orphan if the old run
|
|
155
|
+
// writes later; the raw exact-name sweep below removes those files on the
|
|
156
|
+
// post-runtime retry. The rotated canonical epoch never refers to this id.
|
|
157
|
+
const liveEntry = nativeSessions.get(providerSessionId);
|
|
158
|
+
const providerStillUnwinding = liveEntry?.busy === true;
|
|
146
159
|
// Other processes are serialized by the history coordinator and must pass
|
|
147
160
|
// the same cold-refresh barrier before their next turn.
|
|
148
161
|
await nativeSessions.refresh(providerSessionId);
|
|
@@ -155,16 +168,66 @@ export async function retireDurableNativeSession(providerSessionId, piSessionsRo
|
|
|
155
168
|
if (typeof metadata?.path !== "string" || !metadata.path) {
|
|
156
169
|
throw new Error(`Durable Pi session ${providerSessionId} has invalid metadata`);
|
|
157
170
|
}
|
|
158
|
-
|
|
171
|
+
if (providerStillUnwinding) {
|
|
172
|
+
try {
|
|
173
|
+
await unlink(metadata.path);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error?.code !== "ENOENT") throw error;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
await repo.delete(metadata, PI_CONTEXT);
|
|
179
|
+
}
|
|
159
180
|
changedDirectories.add(dirname(metadata.path));
|
|
160
181
|
}
|
|
182
|
+
// A prior active-session unlink can be followed by Pi recreating the same
|
|
183
|
+
// pathname with a transaction line but no header. JsonlSessionRepo.list()
|
|
184
|
+
// intentionally ignores that invalid file, so scan only Pi's fixed
|
|
185
|
+
// root/directory/filename layout to make the later retirement retry reclaim
|
|
186
|
+
// it. Safe ids are single filename components and symlink directories/files
|
|
187
|
+
// are ignored.
|
|
188
|
+
for (const path of await exactDurableSessionFiles(piSessionsRoot, providerSessionId)) {
|
|
189
|
+
try {
|
|
190
|
+
await unlink(path);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.code !== "ENOENT") throw error;
|
|
193
|
+
}
|
|
194
|
+
changedDirectories.add(dirname(path));
|
|
195
|
+
}
|
|
161
196
|
for (const directory of changedDirectories) await syncPath(directory);
|
|
162
197
|
if (changedDirectories.size > 0) await syncPath(resolve(piSessionsRoot));
|
|
163
198
|
|
|
164
199
|
const remaining = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
|
|
165
|
-
|
|
200
|
+
const remainingPaths = await exactDurableSessionFiles(piSessionsRoot, providerSessionId);
|
|
201
|
+
if (!providerStillUnwinding && (remaining.length > 0 || remainingPaths.length > 0)) {
|
|
166
202
|
throw new Error(`Durable Pi session ${providerSessionId} could not be retired completely`);
|
|
167
203
|
}
|
|
204
|
+
// An active Pi write can race the final exact-name check after the sweep.
|
|
205
|
+
// Canonical history is already preparing a fresh epoch, so the retired id is
|
|
206
|
+
// unreachable; the harness retries retirement when that old run returns.
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function exactDurableSessionFiles(piSessionsRoot, providerSessionId) {
|
|
210
|
+
const root = resolve(piSessionsRoot);
|
|
211
|
+
const suffix = `_${encodeURIComponent(providerSessionId)}.jsonl`;
|
|
212
|
+
let rootEntries;
|
|
213
|
+
try {
|
|
214
|
+
rootEntries = await readdir(root, { withFileTypes: true });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error?.code === "ENOENT") return [];
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
const paths = [];
|
|
220
|
+
for (const directory of rootEntries) {
|
|
221
|
+
if (!directory.isDirectory()) continue;
|
|
222
|
+
const directoryPath = join(root, directory.name);
|
|
223
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
226
|
+
paths.push(join(directoryPath, entry.name));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return paths;
|
|
168
231
|
}
|
|
169
232
|
|
|
170
233
|
// Defense in depth (R4): create-on-miss passes the caller-controlled session id
|
|
@@ -355,7 +418,7 @@ export async function resolveSession(runState, {
|
|
|
355
418
|
// (F4) safe. Do not introduce any await in this span or the TOCTOU window
|
|
356
419
|
// reopens. `claim` re-reads the same registry entry and sets busy in one
|
|
357
420
|
// await-free step; a busy entry loses and returns session_busy.
|
|
358
|
-
const claimed = liveness.claim(requestedSessionId);
|
|
421
|
+
const claimed = entry.recoveryPending ? { ok: /** @type {const} */ (false), reason: "busy" } : liveness.claim(requestedSessionId);
|
|
359
422
|
if (!claimed.ok) {
|
|
360
423
|
// claim() can lose two ways: "busy" (the entry adopted above is
|
|
361
424
|
// mid-turn) or "missing" (no live entry). "missing" is UNREACHABLE on
|
|
@@ -386,6 +449,7 @@ export async function resolveSession(runState, {
|
|
|
386
449
|
};
|
|
387
450
|
}
|
|
388
451
|
runState.sessionEntry = claimed.entry;
|
|
452
|
+
delete claimed.entry.recovery;
|
|
389
453
|
try {
|
|
390
454
|
runState.session = createPiSessionAdapter(await claimed.entry.repo.open(
|
|
391
455
|
claimed.entry.metadata,
|
|
@@ -403,10 +467,11 @@ export async function resolveSession(runState, {
|
|
|
403
467
|
}
|
|
404
468
|
}
|
|
405
469
|
} else {
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
runState.
|
|
470
|
+
// Attribution may equal a live primary's id on a stateless retry/backup.
|
|
471
|
+
// A private ephemeral repo prevents both create collisions and cleanup of
|
|
472
|
+
// that primary's transcript. Only keep-alive calls use a shared repository.
|
|
473
|
+
if (options.sessionKeepAlive !== true) runState.ephemeralSessionRepo = new MemorySessionRepo();
|
|
474
|
+
runState.session = createPiSessionAdapter(await (runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo)
|
|
410
475
|
.create({ id: providerSessionId, cwd: cwd || process.cwd() }, PI_CONTEXT));
|
|
411
476
|
}
|
|
412
477
|
return { done: false };
|
|
@@ -428,7 +493,7 @@ export async function discardUncommittedSession(runState, { durableRepo }) {
|
|
|
428
493
|
if (runState.session && !runState.sessionEntry) {
|
|
429
494
|
await closeAndDeleteSession(
|
|
430
495
|
runState.session,
|
|
431
|
-
durableRepo || nativeSessionRepo,
|
|
496
|
+
runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo,
|
|
432
497
|
);
|
|
433
498
|
}
|
|
434
499
|
// Drop the create-on-miss BUSY reservation too, else the busy placeholder
|
|
@@ -457,7 +522,7 @@ export async function commitSession(runState, {
|
|
|
457
522
|
onEvent,
|
|
458
523
|
}) {
|
|
459
524
|
const { session, sessionEntry, baselineLeafId, reservation } = runState;
|
|
460
|
-
if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
|
|
525
|
+
if (options.sessionKeepAlive === true && ((!externalAbort && !errorMessage) || runState.retainRecoveryTail)) {
|
|
461
526
|
try {
|
|
462
527
|
if (sessionEntry) {
|
|
463
528
|
// Resumed run: the harness appended this run's turns onto the live
|
|
@@ -480,6 +545,8 @@ export async function commitSession(runState, {
|
|
|
480
545
|
else nativeSessions.set(providerSessionId, entry, { idleTimeoutMs: sessionTtlMs });
|
|
481
546
|
runState.registeredSessionEntry = entry;
|
|
482
547
|
}
|
|
548
|
+
const retained = runState.sessionEntry || runState.registeredSessionEntry;
|
|
549
|
+
if (runState.retainRecoveryTail && retained) retained.recoveryPending = !!externalAbort || !!errorMessage;
|
|
483
550
|
} catch (err) {
|
|
484
551
|
// Session persistence must never fail the run; drop the (now
|
|
485
552
|
// inconsistent) session instead of resuming from a broken transcript.
|
|
@@ -513,7 +580,7 @@ export async function commitSession(runState, {
|
|
|
513
580
|
// (the success keep-alive path overwrites it with the finalized entry, so
|
|
514
581
|
// it is only this drop branch that must clean it up).
|
|
515
582
|
if (reservation) reservation.release();
|
|
516
|
-
await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
|
|
583
|
+
await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
|
|
517
584
|
}
|
|
518
585
|
}
|
|
519
586
|
|
|
@@ -534,8 +601,9 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
|
|
|
534
601
|
}
|
|
535
602
|
nativeSessions.delete(requestedSessionId);
|
|
536
603
|
} else {
|
|
537
|
-
|
|
538
|
-
|
|
604
|
+
// A stateless call never registered this id; it may belong to the primary.
|
|
605
|
+
if (!runState.ephemeralSessionRepo) nativeSessions.delete(providerSessionId);
|
|
606
|
+
await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
|
|
539
607
|
}
|
|
540
608
|
}
|
|
541
609
|
|
|
@@ -558,7 +626,7 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
|
|
|
558
626
|
// resumed user session here would be data loss) and never when the throw
|
|
559
627
|
// preceded session create.
|
|
560
628
|
if (session && !sessionEntry) {
|
|
561
|
-
await closeAndDeleteSession(session, durableRepo || nativeSessionRepo);
|
|
629
|
+
await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
|
|
562
630
|
}
|
|
563
631
|
// Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
|
|
564
632
|
// during/after the reservation — including a throw inside the create await
|
|
@@ -582,3 +650,78 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
|
|
|
582
650
|
try { await session.close(); } catch { /* best-effort */ }
|
|
583
651
|
}
|
|
584
652
|
}
|
|
653
|
+
|
|
654
|
+
/** Capture only after close; pending entries cannot be driven by another turn. */
|
|
655
|
+
export async function captureSessionRecovery(runState, { options, providerSessionId, modelKey, model, pending }) {
|
|
656
|
+
const entry = runState.sessionEntry || runState.registeredSessionEntry;
|
|
657
|
+
if (!entry?.durable) return undefined;
|
|
658
|
+
try {
|
|
659
|
+
if (!Array.isArray(runState.recoveryInputIds)) throw new Error("Pi session recovery input identities are unavailable");
|
|
660
|
+
const tipId = await runState.session.getLeafId();
|
|
661
|
+
if (typeof tipId !== "string" || !tipId) throw new Error("Pi session recovery tip is unavailable");
|
|
662
|
+
const ancestry = createHash("sha256").update(JSON.stringify(await runState.session.getEntries())).digest("hex");
|
|
663
|
+
await runState.session.close();
|
|
664
|
+
const receipt = { runId: options.sessionRecovery.runId, revision: options.sessionRecovery.revision, providerSessionId, modelKey, tipId };
|
|
665
|
+
entry.recovery = { receipt: { ...receipt }, model: { ...model, input: [...model.input] }, ancestry, operationId: runState.recoveryOperationId, baselineTipId: runState.recoveryBaselineTipId, inputIds: runState.recoveryInputIds };
|
|
666
|
+
entry.recoveryPending = pending || !!options.abortSignal?.aborted;
|
|
667
|
+
return receipt;
|
|
668
|
+
} catch (error) {
|
|
669
|
+
// The run's outer catch performs legacy rollback/close or fresh deletion.
|
|
670
|
+
// Release provisional recovery state first so failed capture cannot strand
|
|
671
|
+
// an entry as busy without a receipt that could settle it.
|
|
672
|
+
entry.recoveryPending = false;
|
|
673
|
+
delete entry.recovery;
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Read-only settlement: never drive an operation or append host-authored prose. */
|
|
679
|
+
export async function recoverDurableNativeSession(receipt, context) {
|
|
680
|
+
const entry = nativeSessions.get(receipt?.providerSessionId);
|
|
681
|
+
const proof = entry?.recovery;
|
|
682
|
+
if (!entry?.durable || entry.busy || !proof
|
|
683
|
+
|| !["runId", "revision", "providerSessionId", "modelKey", "tipId"].every((key) => receipt[key] === proof.receipt[key])
|
|
684
|
+
|| !Array.isArray(context?.appliedInputIds)
|
|
685
|
+
|| proof.inputIds.some((id) => typeof id !== "string")
|
|
686
|
+
|| JSON.stringify([...proof.inputIds].sort()) !== JSON.stringify([...context.appliedInputIds].sort())) return false;
|
|
687
|
+
entry.busy = true;
|
|
688
|
+
let raw;
|
|
689
|
+
try {
|
|
690
|
+
const matches = (await entry.repo.list(undefined, PI_CONTEXT)).filter((record) => record.id === receipt.providerSessionId);
|
|
691
|
+
if (matches.length !== 1 || matches[0].path !== entry.metadata.path) return false;
|
|
692
|
+
raw = await entry.repo.open(matches[0], PI_CONTEXT);
|
|
693
|
+
const branch = await raw.branch("main", PI_CONTEXT);
|
|
694
|
+
if (!branch || await branch.getTipId(PI_CONTEXT) !== receipt.tipId) return false;
|
|
695
|
+
const state = (await raw.getValue(laneState("main"), PI_CONTEXT))?.value;
|
|
696
|
+
const config = (await raw.getValue(laneConfig("main"), PI_CONTEXT))?.value;
|
|
697
|
+
const terminal = (await raw.getValue(operationResult(proof.operationId), PI_CONTEXT))?.value;
|
|
698
|
+
const meta = (await raw.getValue(operationMeta(proof.operationId), PI_CONTEXT))?.value;
|
|
699
|
+
if (!state || state.currentOperationId !== null || state.lastOperationId !== proof.operationId || state.inbox.length !== 0
|
|
700
|
+
|| config?.model.provider !== proof.model.provider || config?.model.modelId !== proof.model.id
|
|
701
|
+
|| !terminal || !["completed", "failed", "aborted"].includes(terminal.status) || terminal.tipId !== receipt.tipId
|
|
702
|
+
|| meta !== undefined || terminal.kind !== "run" || terminal.fromTipId !== proof.baselineTipId
|
|
703
|
+
|| await raw.getValue(operationState(proof.operationId), PI_CONTEXT)) return false;
|
|
704
|
+
const entries = await branch.findEntries({ order: "oldestFirst" }, PI_CONTEXT);
|
|
705
|
+
if (createHash("sha256").update(JSON.stringify(entries)).digest("hex") !== proof.ancestry) return false;
|
|
706
|
+
const baseline = proof.baselineTipId === null ? -1 : entries.findIndex((item) => item.id === proof.baselineTipId);
|
|
707
|
+
if (proof.baselineTipId !== null && baseline < 0) return false;
|
|
708
|
+
const tail = entries.slice(baseline + 1);
|
|
709
|
+
if (tail[0]?.type !== "message" || tail[0].message.role !== "user"
|
|
710
|
+
|| tail.filter((item) => item.type === "message" && item.message.role === "user").length !== 1 + proof.inputIds.length) return false;
|
|
711
|
+
if (!validRecoveryProjection(buildPiSessionContext(entries), proof.model)) return false;
|
|
712
|
+
await raw.close(PI_CONTEXT);
|
|
713
|
+
raw = undefined;
|
|
714
|
+
// Pending is cleared only after persistence is certain. Bypass the ordinary
|
|
715
|
+
// sync guard while keeping the public busy reservation throughout the fsync.
|
|
716
|
+
await syncPath(entry.metadata.path);
|
|
717
|
+
await syncPath(dirname(entry.metadata.path));
|
|
718
|
+
entry.recoveryPending = false;
|
|
719
|
+
delete entry.recovery;
|
|
720
|
+
return true;
|
|
721
|
+
} catch {
|
|
722
|
+
return false;
|
|
723
|
+
} finally {
|
|
724
|
+
try { await raw?.close(PI_CONTEXT); } catch { /* already failed closed */ }
|
|
725
|
+
entry.busy = false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
@@ -32,12 +32,30 @@ function toolResultOutcome(result) {
|
|
|
32
32
|
code: "code",
|
|
33
33
|
backend: "backend",
|
|
34
34
|
signal: "signal",
|
|
35
|
+
contentKind: "content_kind",
|
|
36
|
+
charset: "charset",
|
|
37
|
+
charsetSource: "charset_source",
|
|
38
|
+
extractionStage: "extraction_stage",
|
|
39
|
+
renderReason: "render_reason",
|
|
40
|
+
retryAt: "retry_at",
|
|
41
|
+
nextAction: "next_action",
|
|
35
42
|
};
|
|
36
43
|
const numbers = {
|
|
37
44
|
attempts: "attempts",
|
|
45
|
+
queueWaitMs: "queue_wait_ms",
|
|
46
|
+
backendDurationMs: "backend_duration_ms",
|
|
47
|
+
retryAfterMs: "retry_after_ms",
|
|
48
|
+
maxRequestsPerRun: "max_requests_per_run",
|
|
49
|
+
requestsThisCall: "requests_this_call",
|
|
50
|
+
requestsUsed: "requests_used",
|
|
51
|
+
requestsRemaining: "requests_remaining",
|
|
52
|
+
cooldownSkipCount: "cooldown_skip_count",
|
|
53
|
+
quotaSkipCount: "quota_skip_count",
|
|
38
54
|
bytes: "bytes",
|
|
39
55
|
exitCode: "exit_code",
|
|
40
56
|
statusCode: "status_code",
|
|
57
|
+
redirectCount: "redirect_count",
|
|
58
|
+
parserFailureCount: "parser_failure_count",
|
|
41
59
|
};
|
|
42
60
|
const booleans = {
|
|
43
61
|
retryable: "retryable",
|
|
@@ -45,6 +63,11 @@ function toolResultOutcome(result) {
|
|
|
45
63
|
truncated: "truncated",
|
|
46
64
|
timedOut: "timed_out",
|
|
47
65
|
rendered: "rendered",
|
|
66
|
+
renderFailed: "render_failed",
|
|
67
|
+
browserRecommended: "browser_recommended",
|
|
68
|
+
retryInRun: "retry_in_run",
|
|
69
|
+
fallbackUsed: "fallback_used",
|
|
70
|
+
hadDecodingReplacement: "had_decoding_replacement",
|
|
48
71
|
};
|
|
49
72
|
for (const [input, output] of Object.entries(strings)) {
|
|
50
73
|
if (typeof source[input] === "string") bounded[output] = source[input].slice(0, 120);
|
|
@@ -110,18 +133,23 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
110
133
|
} else if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
111
134
|
const contextUsage = contextUsageFromAssistantMessage(event.message);
|
|
112
135
|
if (contextUsage) {
|
|
136
|
+
const { costUsd, ...contextTokens } = contextUsage;
|
|
113
137
|
const contextWindow = Number(harness?.getModel?.()?.contextWindow) || 0;
|
|
114
138
|
const measurementId = typeof event.message?.id === "string" && event.message.id.trim().length > 0
|
|
115
139
|
? event.message.id
|
|
116
140
|
: undefined;
|
|
117
141
|
onEvent({
|
|
118
142
|
type: "context_usage",
|
|
143
|
+
...harness?.getPromptCacheRequest?.(),
|
|
119
144
|
sdk,
|
|
120
145
|
model,
|
|
121
146
|
timestamp: Date.now(),
|
|
122
147
|
...(measurementId === undefined ? {} : { measurementId }),
|
|
123
148
|
...(contextWindow > 0 ? { contextWindow } : {}),
|
|
124
|
-
tokens:
|
|
149
|
+
tokens: contextTokens,
|
|
150
|
+
costUsd,
|
|
151
|
+
providerCostUsd: typeof event.message.usage?.cost?.total === "number" && Number.isFinite(event.message.usage.cost.total) && event.message.usage.cost.total >= 0 ? event.message.usage.cost.total : null,
|
|
152
|
+
costSource: "pi_usage",
|
|
125
153
|
});
|
|
126
154
|
}
|
|
127
155
|
const hasVisibleContent = Array.isArray(event.message?.content)
|
|
@@ -216,7 +244,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
|
|
|
216
244
|
});
|
|
217
245
|
} else if (event.type === "turn_end") {
|
|
218
246
|
runState.turnCount += 1;
|
|
219
|
-
// NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.85.
|
|
247
|
+
// NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.85.1).
|
|
220
248
|
// pi's only after-turn stop hook is `shouldStopAfterTurn` on the LOW-LEVEL
|
|
221
249
|
// `AgentLoopConfig` (dist/types.d.ts) — the config passed to the raw
|
|
222
250
|
// `agentLoop`. It is NOT surfaced on `AgentHarnessOptions`
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
|
|
3
|
+
|
|
4
|
+
/** Validate the effective provider projection; Pi supplies honest missing results. */
|
|
5
|
+
export function validRecoveryProjection(messages, model) {
|
|
6
|
+
try {
|
|
7
|
+
const projected = transformMessages(messages, model);
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
const pending = new Map();
|
|
10
|
+
for (const message of projected) {
|
|
11
|
+
if (!["user", "assistant", "toolResult"].includes(message?.role)) return false;
|
|
12
|
+
if (message.role !== "toolResult" && pending.size) return false;
|
|
13
|
+
if (message.role === "user" && typeof message.content === "string") continue;
|
|
14
|
+
if (!Array.isArray(message.content)) return false;
|
|
15
|
+
if (message.role === "toolResult") {
|
|
16
|
+
if (!pending.has(message.toolCallId) || pending.get(message.toolCallId) !== message.toolName) return false;
|
|
17
|
+
pending.delete(message.toolCallId);
|
|
18
|
+
}
|
|
19
|
+
if (message.role === "assistant" && ["error", "aborted", "deferred"].includes(message.stopReason)) return false;
|
|
20
|
+
for (const block of message.content) {
|
|
21
|
+
if (block?.type === "text") { if (typeof block.text !== "string") return false; }
|
|
22
|
+
else if (block?.type === "image") { if (typeof block.data !== "string" || typeof block.mimeType !== "string") return false; }
|
|
23
|
+
else if (block?.type === "thinking") {
|
|
24
|
+
if (message.role !== "assistant" || typeof block.thinking !== "string"
|
|
25
|
+
|| (block.thinkingSignature !== undefined && (typeof block.thinkingSignature !== "string" || !block.thinkingSignature))) return false;
|
|
26
|
+
if (block.thinkingSignature && String(model.api).includes("responses")) {
|
|
27
|
+
const signature = JSON.parse(block.thinkingSignature);
|
|
28
|
+
if (signature?.type !== "reasoning" || typeof signature.id !== "string" || !Array.isArray(signature.summary)) return false;
|
|
29
|
+
}
|
|
30
|
+
} else if (block?.type === "toolCall") {
|
|
31
|
+
if (message.role !== "assistant" || typeof block.id !== "string" || !block.id || seen.has(block.id)
|
|
32
|
+
|| typeof block.name !== "string" || !block.name || !block.arguments || typeof block.arguments !== "object") return false;
|
|
33
|
+
seen.add(block.id);
|
|
34
|
+
pending.set(block.id, block.name);
|
|
35
|
+
} else return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return pending.size === 0;
|
|
39
|
+
} catch { return false; }
|
|
40
|
+
}
|