@mono-agent/agent-runtime 0.3.0 → 0.4.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/ARCHITECTURE.md +75 -9
- package/MIGRATION.md +293 -0
- package/README.md +46 -20
- package/package.json +104 -10
- package/src/agent/allowlists.js +3 -0
- package/src/agent/approval.js +3 -0
- package/src/agent/compaction.js +231 -654
- package/src/agent/index.js +1 -1
- package/src/agent/prompt/skill-index.js +6 -0
- package/src/agent/sandbox-seam.js +227 -0
- package/src/agent/tool-bloat.js +8 -2
- package/src/agent/tools/bash.js +16 -8
- package/src/agent/tools/edit.js +7 -3
- package/src/agent/tools/glob.js +11 -5
- package/src/agent/tools/grep.js +11 -5
- package/src/agent/tools/pi-bridge.js +272 -54
- package/src/agent/tools/read.js +28 -3
- package/src/agent/tools/shared/output-truncation.js +8 -3
- package/src/agent/tools/shared/path-resolver.js +24 -18
- package/src/agent/tools/shared/ripgrep.js +33 -6
- package/src/agent/tools/shared/runtime-context.js +60 -50
- package/src/agent/tools/shared/tool-context.js +157 -0
- package/src/agent/tools/web-fetch.js +65 -18
- package/src/agent/tools/web-search.js +11 -4
- package/src/agent/tools/write.js +7 -3
- package/src/agent/transcript.js +16 -1
- package/src/ai/cost.js +128 -3
- package/src/ai/failure.js +96 -4
- package/src/ai/index.js +1 -0
- package/src/ai/live-input-prompt.js +11 -1
- package/src/ai/providers/claude-cli.js +6 -2
- package/src/ai/providers/claude-sdk.js +55 -12
- package/src/ai/providers/codex-app.js +36 -23
- package/src/ai/providers/opencode-app.js +2 -2
- package/src/ai/providers/opencode-discovery.js +2 -2
- package/src/ai/providers/pi-errors.js +65 -0
- package/src/ai/providers/pi-events.js +5 -0
- package/src/ai/providers/pi-models.js +21 -4
- package/src/ai/providers/pi-native/compaction-driver.js +394 -0
- package/src/ai/providers/pi-native/result-builder.js +312 -0
- package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
- package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
- package/src/ai/providers/pi-native/structured-output.js +130 -0
- package/src/ai/providers/pi-native/turn-runner.js +278 -0
- package/src/ai/providers/pi-native.js +754 -0
- package/src/ai/runtime/capabilities.js +3 -0
- package/src/ai/runtime/model-refs.js +30 -0
- package/src/ai/runtime/registry.js +33 -2
- package/src/ai/runtime/router.js +119 -19
- package/src/ai/runtime/session-liveness.js +110 -0
- package/src/ai/runtime/sessions.js +19 -2
- package/src/ai/types.js +252 -20
- package/src/index.js +1 -1
- package/src/pi-auth.js +147 -16
- package/src/runtime-brand.js +21 -1
- package/src/runtime.js +75 -10
- package/types/agent/allowlists.d.ts +25 -0
- package/types/agent/approval.d.ts +30 -0
- package/types/agent/compaction.d.ts +97 -0
- package/types/agent/index.d.ts +5 -0
- package/types/agent/prompt/skill-index.d.ts +19 -0
- package/types/agent/sandbox-seam.d.ts +148 -0
- package/types/agent/tool-bloat.d.ts +22 -0
- package/types/agent/tools/bash.d.ts +16 -0
- package/types/agent/tools/edit.d.ts +14 -0
- package/types/agent/tools/glob.d.ts +16 -0
- package/types/agent/tools/grep.d.ts +22 -0
- package/types/agent/tools/index.d.ts +10 -0
- package/types/agent/tools/pi-bridge.d.ts +144 -0
- package/types/agent/tools/read.d.ts +19 -0
- package/types/agent/tools/shared/constants.d.ts +13 -0
- package/types/agent/tools/shared/dedup.d.ts +8 -0
- package/types/agent/tools/shared/output-truncation.d.ts +22 -0
- package/types/agent/tools/shared/path-resolver.d.ts +6 -0
- package/types/agent/tools/shared/ripgrep.d.ts +38 -0
- package/types/agent/tools/shared/runtime-context.d.ts +27 -0
- package/types/agent/tools/shared/tool-context.d.ts +48 -0
- package/types/agent/tools/web-fetch.d.ts +13 -0
- package/types/agent/tools/web-search.d.ts +11 -0
- package/types/agent/tools/write.d.ts +12 -0
- package/types/agent/transcript.d.ts +45 -0
- package/types/ai/backend.d.ts +41 -0
- package/types/ai/cost.d.ts +96 -0
- package/types/ai/failure.d.ts +117 -0
- package/types/ai/file-change-stats.d.ts +75 -0
- package/types/ai/index.d.ts +7 -0
- package/types/ai/live-input-prompt.d.ts +6 -0
- package/types/ai/observer.d.ts +68 -0
- package/types/ai/providers/claude-cli.d.ts +211 -0
- package/types/ai/providers/claude-sdk.d.ts +66 -0
- package/types/ai/providers/claude-subagents.d.ts +2 -0
- package/types/ai/providers/codex-app.d.ts +151 -0
- package/types/ai/providers/opencode-app.d.ts +95 -0
- package/types/ai/providers/opencode-discovery.d.ts +4 -0
- package/types/ai/providers/pi-errors.d.ts +3 -0
- package/types/ai/providers/pi-events.d.ts +24 -0
- package/types/ai/providers/pi-messages.d.ts +5 -0
- package/types/ai/providers/pi-models.d.ts +57 -0
- package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
- package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
- package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
- package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
- package/types/ai/providers/pi-native.d.ts +18 -0
- package/types/ai/registry.d.ts +1 -0
- package/types/ai/runtime/capabilities-used.d.ts +21 -0
- package/types/ai/runtime/capabilities.d.ts +33 -0
- package/types/ai/runtime/context-windows.d.ts +8 -0
- package/types/ai/runtime/fast-mode.d.ts +2 -0
- package/types/ai/runtime/model-refs.d.ts +35 -0
- package/types/ai/runtime/registry.d.ts +38 -0
- package/types/ai/runtime/router.d.ts +56 -0
- package/types/ai/runtime/session-liveness.d.ts +55 -0
- package/types/ai/runtime/sessions.d.ts +38 -0
- package/types/ai/streaming/codex-events.d.ts +30 -0
- package/types/ai/streaming/opencode-events.d.ts +41 -0
- package/types/ai/types.d.ts +702 -0
- package/types/index.d.ts +7 -0
- package/types/pi-auth.d.ts +28 -0
- package/types/runtime-brand.d.ts +30 -0
- package/types/runtime.d.ts +10 -0
- package/src/ai/providers/pi-sdk.js +0 -1310
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Session lifecycle for the pi-native bridge.
|
|
3
|
+
//
|
|
4
|
+
// Pure moves out of pi-native.js: the durable-repo resolve/reopen, the safe-id
|
|
5
|
+
// gate (R4), the resume / create-on-miss / claim flow (I1/I2/I3/I4/I5, R8/F4),
|
|
6
|
+
// the keep-alive commit + rollback + drop paths, and sessionUnavailableResult.
|
|
7
|
+
// The process-level session storage (the registry + repos) legitimately stays
|
|
8
|
+
// module-level here (it must persist across runs); per-RUN state lives on the
|
|
9
|
+
// caller-owned runState. Concurrency claims go through the synchronous
|
|
10
|
+
// createSessionLiveness primitives so the await-free spans are enforced by
|
|
11
|
+
// construction rather than by inline sequencing.
|
|
12
|
+
|
|
13
|
+
import { InMemorySessionRepo, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
|
|
14
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
15
|
+
import { createSessionRegistry } from "../../runtime/sessions.js";
|
|
16
|
+
import { createSessionLiveness } from "../../runtime/session-liveness.js";
|
|
17
|
+
|
|
18
|
+
// Live pi-native sessions, keyed by provider session id. Entries are
|
|
19
|
+
// { session, metadata, repo, durable, busy } — identical shape and lifecycle
|
|
20
|
+
// policy to the (now-retired) pi-sdk bridge: in-memory transcripts are freed
|
|
21
|
+
// when the registry evicts them; durable (jsonl) transcripts survive eviction
|
|
22
|
+
// so a later resume can reopen them from disk. Registering here gives
|
|
23
|
+
// runtime.disposeSession / disposeProviderSession + idle-TTL eviction the same
|
|
24
|
+
// reach over native pi sessions that the legacy bridge had.
|
|
25
|
+
const nativeSessionRepo = new InMemorySessionRepo();
|
|
26
|
+
const nativeSessions = createSessionRegistry({
|
|
27
|
+
isBusy: (entry) => entry.busy === true,
|
|
28
|
+
onEvict: async (entry) => {
|
|
29
|
+
if (entry.durable) return;
|
|
30
|
+
try {
|
|
31
|
+
await entry.repo.delete(entry.metadata);
|
|
32
|
+
} catch { /* best-effort */ }
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const liveness = createSessionLiveness(nativeSessions);
|
|
36
|
+
|
|
37
|
+
const durableNativeSessionRepos = new Map();
|
|
38
|
+
|
|
39
|
+
export function resolveDurableNativeSessionRepo(piSessionsRoot) {
|
|
40
|
+
if (typeof piSessionsRoot !== "string" || !piSessionsRoot.trim()) return null;
|
|
41
|
+
const root = piSessionsRoot.trim();
|
|
42
|
+
let repo = durableNativeSessionRepos.get(root);
|
|
43
|
+
if (!repo) {
|
|
44
|
+
repo = new JsonlSessionRepo({
|
|
45
|
+
fs: new NodeExecutionEnv({ cwd: process.cwd() }),
|
|
46
|
+
sessionsRoot: root,
|
|
47
|
+
});
|
|
48
|
+
durableNativeSessionRepos.set(root, repo);
|
|
49
|
+
}
|
|
50
|
+
return repo;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Defense in depth (R4): create-on-miss passes the caller-controlled session id
|
|
54
|
+
// straight to durableRepo.create({ id }), and JsonlSessionRepo writes
|
|
55
|
+
// `${createdAt}_${id}.jsonl` — so an id like "../../../../tmp/pwn" would escape
|
|
56
|
+
// piSessionsRoot and name a file anywhere on disk. The harness-derived id is a
|
|
57
|
+
// sha256 hex (always safe), but the public runtime API is caller-controlled.
|
|
58
|
+
// Only an id that is a single safe filename component may CREATE a session;
|
|
59
|
+
// anything else falls through to the existing session_not_found fast-fail, so a
|
|
60
|
+
// malicious id can never name a file. (A genuinely on-disk session reopened by
|
|
61
|
+
// reopenDurableNativeSession is matched by `.id`, never used to build a path, so
|
|
62
|
+
// this gate is confined to the create path.)
|
|
63
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
64
|
+
|
|
65
|
+
function isSafeSessionId(id) {
|
|
66
|
+
return typeof id === "string"
|
|
67
|
+
&& SAFE_SESSION_ID.test(id)
|
|
68
|
+
&& !id.includes("..")
|
|
69
|
+
&& !id.includes("/")
|
|
70
|
+
&& !id.includes("\\");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function reopenDurableNativeSession(repo, sessionId) {
|
|
74
|
+
try {
|
|
75
|
+
const metadata = (await repo.list()).find((entry) => entry?.id === sessionId);
|
|
76
|
+
if (!metadata) return null;
|
|
77
|
+
const session = await repo.open(metadata);
|
|
78
|
+
return { session, metadata, repo, durable: true, busy: false };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sessionUnavailableResult({
|
|
85
|
+
resolved,
|
|
86
|
+
options,
|
|
87
|
+
events,
|
|
88
|
+
runtimeWarnings,
|
|
89
|
+
start,
|
|
90
|
+
sessionId,
|
|
91
|
+
errorMessage,
|
|
92
|
+
failureKind,
|
|
93
|
+
piErrorCode,
|
|
94
|
+
}) {
|
|
95
|
+
return {
|
|
96
|
+
text: null,
|
|
97
|
+
events,
|
|
98
|
+
usage: {},
|
|
99
|
+
durationMs: Date.now() - start,
|
|
100
|
+
numTurns: 0,
|
|
101
|
+
model: resolved?.reference || resolved?.model || null,
|
|
102
|
+
effort: options.effort || null,
|
|
103
|
+
sdk: resolved?.sdk || "pi",
|
|
104
|
+
cancelled: false,
|
|
105
|
+
error: errorMessage,
|
|
106
|
+
failureKind,
|
|
107
|
+
providerSessionId: sessionId,
|
|
108
|
+
runtimeWarnings,
|
|
109
|
+
diagnostics: {
|
|
110
|
+
provider_session_id: sessionId,
|
|
111
|
+
pi_error_code: piErrorCode,
|
|
112
|
+
pi_engine: "native",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the session for this run: warm registry hit, durable cold reopen,
|
|
119
|
+
* create-on-miss (durable resume only), or fresh create. Mutates runState
|
|
120
|
+
* (session / sessionEntry / createdOnMiss / reservation). Returns
|
|
121
|
+
* `{ done: true, result }` for a fast-fail early return (session_not_found /
|
|
122
|
+
* session_busy), else `{ done: false }` to proceed.
|
|
123
|
+
* @param {any} runState
|
|
124
|
+
* @param {any} params
|
|
125
|
+
* @returns {Promise<{done: true, result: any} | {done: false}>}
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveSession(runState, {
|
|
128
|
+
requestedSessionId,
|
|
129
|
+
providerSessionId,
|
|
130
|
+
durableRepo,
|
|
131
|
+
sessionTtlMs,
|
|
132
|
+
cwd,
|
|
133
|
+
resolved,
|
|
134
|
+
options,
|
|
135
|
+
events,
|
|
136
|
+
runtimeWarnings,
|
|
137
|
+
start,
|
|
138
|
+
}) {
|
|
139
|
+
// Resume check first: a session miss must stay cheap (no tool/MCP/harness
|
|
140
|
+
// init). This mirrors the legacy bridge's fail-fast contract.
|
|
141
|
+
if (requestedSessionId) {
|
|
142
|
+
let entry = liveness.adoptIfPresent(requestedSessionId);
|
|
143
|
+
if (!entry && durableRepo) {
|
|
144
|
+
entry = await reopenDurableNativeSession(durableRepo, requestedSessionId);
|
|
145
|
+
if (entry) {
|
|
146
|
+
// TOCTOU guard: the reopen above is an AWAIT, so a second concurrent
|
|
147
|
+
// cold resume could have reopened+inserted its own entry in this
|
|
148
|
+
// window. Re-read the registry and adopt any entry already present so
|
|
149
|
+
// the busy-claim below collapses back to the warm path's synchronous
|
|
150
|
+
// semantics (the loser sees the winner's shared entry with busy===true
|
|
151
|
+
// and returns session_busy). The discarded reopen is just an in-memory
|
|
152
|
+
// jsonl handle (no subprocess/socket), so dropping it is safe.
|
|
153
|
+
const concurrent = liveness.adoptIfPresent(requestedSessionId);
|
|
154
|
+
if (concurrent) {
|
|
155
|
+
entry = concurrent;
|
|
156
|
+
} else {
|
|
157
|
+
nativeSessions.set(requestedSessionId, entry, { idleTimeoutMs: sessionTtlMs });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!entry) {
|
|
162
|
+
if (durableRepo && isSafeSessionId(providerSessionId)) {
|
|
163
|
+
// Create-on-miss (durable resume only): the requested id has no live
|
|
164
|
+
// registry entry AND no JSONL on disk under piSessionsRoot. This is
|
|
165
|
+
// the cross-restart resume case — the harness derives a stable id from
|
|
166
|
+
// the conversationId and passes it before any session exists on a
|
|
167
|
+
// fresh process. Rather than fail with session_not_found (which would
|
|
168
|
+
// make the harness re-send full history into yet another fresh,
|
|
169
|
+
// randomly-named session and orphan future resumes), create a durable
|
|
170
|
+
// session UNDER the requested id so this and every later turn for the
|
|
171
|
+
// conversation resolve to the same on-disk transcript. sessionEntry
|
|
172
|
+
// stays null so this proceeds exactly like a fresh run (prior messages
|
|
173
|
+
// are seeded, the keep-alive success path registers + persists it).
|
|
174
|
+
// The IN-MEMORY resume miss (no durableRepo) — and a create-on-miss
|
|
175
|
+
// with an UNSAFE id (R4) — keep fast-failing below, preserving the
|
|
176
|
+
// existing per-process session_not_found contract.
|
|
177
|
+
//
|
|
178
|
+
// Concurrent-first-turn race (R8): two concurrent first turns for the
|
|
179
|
+
// same durable id would BOTH miss here and BOTH create, producing two
|
|
180
|
+
// transcripts for one logical id (JsonlSessionRepo names files by
|
|
181
|
+
// `${createdAt}_${id}`, so there is no fs-level dedup). Mirror the
|
|
182
|
+
// cold-reopen-race defense: synchronously (NO await) re-check the
|
|
183
|
+
// registry, then reserve the id with a BUSY placeholder before the
|
|
184
|
+
// create await. The get→check→set span MUST stay await-free, so the
|
|
185
|
+
// loser observes the busy placeholder and returns session_busy via the
|
|
186
|
+
// same busy-claim path below — exactly one create per durable id.
|
|
187
|
+
const reservation = liveness.reserve(requestedSessionId, {
|
|
188
|
+
session: null,
|
|
189
|
+
metadata: null,
|
|
190
|
+
repo: durableRepo,
|
|
191
|
+
durable: true,
|
|
192
|
+
busy: true,
|
|
193
|
+
}, sessionTtlMs);
|
|
194
|
+
if (!reservation.ok) {
|
|
195
|
+
// A concurrent caller already reserved/created this id in the window
|
|
196
|
+
// since the miss above. Adopt its entry and fall into the busy-claim
|
|
197
|
+
// logic (session_busy if its turn is in flight, else resume). Cast:
|
|
198
|
+
// @ts-check does not narrow the ReserveResult typedef union on
|
|
199
|
+
// `!reservation.ok`, though the loser branch always carries `entry`.
|
|
200
|
+
entry = /** @type {{entry: any}} */ (reservation).entry;
|
|
201
|
+
} else {
|
|
202
|
+
// Reserved the id with a busy placeholder BEFORE the create await so a
|
|
203
|
+
// second concurrent first turn observes busy and returns session_busy.
|
|
204
|
+
// The keep-alive success path (reservation.commit with busy:false)
|
|
205
|
+
// overwrites this placeholder on success; the drop/abort/catch paths
|
|
206
|
+
// release it. Keyed by requestedSessionId === providerSessionId.
|
|
207
|
+
runState.reservation = reservation;
|
|
208
|
+
runState.session = await durableRepo.create({ id: providerSessionId, cwd: cwd || process.cwd() });
|
|
209
|
+
runState.createdOnMiss = true;
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
return {
|
|
213
|
+
done: true,
|
|
214
|
+
result: sessionUnavailableResult({
|
|
215
|
+
resolved,
|
|
216
|
+
options,
|
|
217
|
+
events,
|
|
218
|
+
runtimeWarnings,
|
|
219
|
+
start,
|
|
220
|
+
sessionId: requestedSessionId,
|
|
221
|
+
errorMessage: `Pi session ${requestedSessionId} is not live`,
|
|
222
|
+
failureKind: "session_not_found",
|
|
223
|
+
piErrorCode: "pi_session_not_found",
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (entry && !runState.createdOnMiss) {
|
|
229
|
+
// The busy claim MUST stay await-free between the registry adoption
|
|
230
|
+
// above and `entry.busy = true` inside claim(): adopt/reserve/set + this
|
|
231
|
+
// claim are all synchronous, which is what makes the cold-resume race
|
|
232
|
+
// (F4) safe. Do not introduce any await in this span or the TOCTOU window
|
|
233
|
+
// reopens. `claim` re-reads the same registry entry and sets busy in one
|
|
234
|
+
// await-free step; a busy entry loses and returns session_busy.
|
|
235
|
+
const claimed = liveness.claim(requestedSessionId);
|
|
236
|
+
if (!claimed.ok) {
|
|
237
|
+
// claim() can lose two ways: "busy" (the entry adopted above is
|
|
238
|
+
// mid-turn) or "missing" (no live entry). "missing" is UNREACHABLE on
|
|
239
|
+
// this path today — the entry was adopted/reserved synchronously in the
|
|
240
|
+
// await-free span just above, so it is always present here — but branch
|
|
241
|
+
// on it anyway so a future refactor that could drop the entry in this
|
|
242
|
+
// window self-defends with session_not_found instead of a misleading
|
|
243
|
+
// "busy" message. The busy branch is byte-identical to before. Cast:
|
|
244
|
+
// @ts-check does not narrow the ClaimResult union on `!claimed.ok`,
|
|
245
|
+
// though the loser branch always carries `reason`.
|
|
246
|
+
const missing = /** @type {{reason: string}} */ (claimed).reason === "missing";
|
|
247
|
+
return {
|
|
248
|
+
done: true,
|
|
249
|
+
result: sessionUnavailableResult({
|
|
250
|
+
resolved,
|
|
251
|
+
options,
|
|
252
|
+
events,
|
|
253
|
+
runtimeWarnings,
|
|
254
|
+
start,
|
|
255
|
+
sessionId: requestedSessionId,
|
|
256
|
+
errorMessage: missing
|
|
257
|
+
? `Pi session ${requestedSessionId} is not live`
|
|
258
|
+
: `Pi session ${requestedSessionId} is busy with another turn`,
|
|
259
|
+
failureKind: missing ? "session_not_found" : "session_busy",
|
|
260
|
+
piErrorCode: missing ? "pi_session_not_found" : "pi_session_busy",
|
|
261
|
+
}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
runState.sessionEntry = claimed.entry;
|
|
265
|
+
runState.session = claimed.entry.session;
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// Fresh runs persist into the durable jsonl repo when piSessionsRoot is
|
|
269
|
+
// set, so a kept-alive session can be reopened from disk after the live
|
|
270
|
+
// entry is evicted; otherwise the in-memory repo is used.
|
|
271
|
+
runState.session = await (durableRepo || nativeSessionRepo)
|
|
272
|
+
.create({ id: providerSessionId, cwd: cwd || process.cwd() });
|
|
273
|
+
}
|
|
274
|
+
return { done: false };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Drop an uncommitted fresh session (and any create-on-miss reservation) on the
|
|
279
|
+
* pre-request abort path. A resumed (user-owned) session is NEVER deleted
|
|
280
|
+
* (guarded `session && !sessionEntry`).
|
|
281
|
+
* @param {any} runState
|
|
282
|
+
* @param {{durableRepo: any}} params
|
|
283
|
+
*/
|
|
284
|
+
export async function discardUncommittedSession(runState, { durableRepo }) {
|
|
285
|
+
// Drop a freshly-created non-keep-alive session so an aborted-before-run turn
|
|
286
|
+
// does not leave an orphan jsonl on disk. Guarded `session && !sessionEntry`
|
|
287
|
+
// so a resumed (user-owned) session is NEVER deleted. For a resume no
|
|
288
|
+
// transcript was appended yet (prompt never ran), so the live session is
|
|
289
|
+
// already at its pre-turn leaf and needs no rollback.
|
|
290
|
+
if (runState.session && !runState.sessionEntry) {
|
|
291
|
+
try { await (durableRepo || nativeSessionRepo).delete(await runState.session.getMetadata()); } catch { /* best-effort */ }
|
|
292
|
+
}
|
|
293
|
+
// Drop the create-on-miss BUSY reservation too, else the busy placeholder
|
|
294
|
+
// leaks and every future resume of this conversation's stable id returns
|
|
295
|
+
// session_busy forever (busy entries are never idle-evicted).
|
|
296
|
+
if (runState.reservation) runState.reservation.release();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Session lifecycle commit: keep-alive registration, resumed-turn rollback, or
|
|
301
|
+
* fresh/non-keep-alive drop. The harness already durably persisted the
|
|
302
|
+
* transcript; this tracks LIVENESS so disposeProviderSession / idle-TTL
|
|
303
|
+
* eviction can reach native sessions, and rolls a failed/aborted resumed turn
|
|
304
|
+
* back to its pre-turn leaf.
|
|
305
|
+
* @param {any} runState
|
|
306
|
+
* @param {any} params
|
|
307
|
+
*/
|
|
308
|
+
export async function commitSession(runState, {
|
|
309
|
+
options,
|
|
310
|
+
requestedSessionId,
|
|
311
|
+
providerSessionId,
|
|
312
|
+
durableRepo,
|
|
313
|
+
sessionTtlMs,
|
|
314
|
+
externalAbort,
|
|
315
|
+
errorMessage,
|
|
316
|
+
onEvent,
|
|
317
|
+
}) {
|
|
318
|
+
const { session, sessionEntry, baselineLeafId, reservation } = runState;
|
|
319
|
+
if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
|
|
320
|
+
try {
|
|
321
|
+
if (sessionEntry) {
|
|
322
|
+
// Resumed run: the harness appended this run's turns onto the live
|
|
323
|
+
// session; just re-arm the idle window.
|
|
324
|
+
nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
|
|
325
|
+
// Surface a write failure the harness swallowed: a session that can
|
|
326
|
+
// no longer persist must not pretend to be resumable.
|
|
327
|
+
await session.buildContext();
|
|
328
|
+
} else {
|
|
329
|
+
const metadata = await session.getMetadata();
|
|
330
|
+
const entry = {
|
|
331
|
+
session,
|
|
332
|
+
metadata,
|
|
333
|
+
repo: durableRepo || nativeSessionRepo,
|
|
334
|
+
durable: !!durableRepo,
|
|
335
|
+
busy: false,
|
|
336
|
+
};
|
|
337
|
+
// A create-on-miss reservation is overwritten by its commit (same id);
|
|
338
|
+
// a plain fresh keep-alive run registers directly.
|
|
339
|
+
if (reservation) reservation.commit(entry);
|
|
340
|
+
else nativeSessions.set(providerSessionId, entry, { idleTimeoutMs: sessionTtlMs });
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// Session persistence must never fail the run; drop the (now
|
|
344
|
+
// inconsistent) session instead of resuming from a broken transcript.
|
|
345
|
+
onEvent({
|
|
346
|
+
type: "runtime_warning",
|
|
347
|
+
warning_kind: "pi_session_persist_failed",
|
|
348
|
+
message: err?.message || String(err),
|
|
349
|
+
});
|
|
350
|
+
nativeSessions.delete(providerSessionId);
|
|
351
|
+
if (requestedSessionId) nativeSessions.delete(requestedSessionId);
|
|
352
|
+
const broken = sessionEntry;
|
|
353
|
+
if (broken) {
|
|
354
|
+
try { await broken.repo.delete(broken.metadata); } catch { /* best-effort */ }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} else if (sessionEntry) {
|
|
358
|
+
// Resumed run that errored (or was aborted): roll the live session back to
|
|
359
|
+
// the leaf captured before this turn so the failed turn never leaks into a
|
|
360
|
+
// later resume. The next resume then sees the last good transcript. The
|
|
361
|
+
// entry stays live (busy is cleared in finally) and its idle TTL re-arms.
|
|
362
|
+
if (baselineLeafId && (errorMessage || externalAbort)) {
|
|
363
|
+
try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
|
|
364
|
+
}
|
|
365
|
+
nativeSessions.touch(requestedSessionId, { idleTimeoutMs: sessionTtlMs });
|
|
366
|
+
} else {
|
|
367
|
+
// Fresh, non-keep-alive (or failed first) run: never leave a live session
|
|
368
|
+
// behind. A durable jsonl transcript on disk is dropped too, matching the
|
|
369
|
+
// legacy default contract that a non-keep-alive run is not resumable.
|
|
370
|
+
// A create-on-miss BUSY reservation (R8) is released here too so a
|
|
371
|
+
// non-keep-alive / errored / aborted first turn never leaks a busy entry
|
|
372
|
+
// (the success keep-alive path overwrites it with the finalized entry, so
|
|
373
|
+
// it is only this drop branch that must clean it up).
|
|
374
|
+
if (reservation) reservation.release();
|
|
375
|
+
try {
|
|
376
|
+
await (durableRepo || nativeSessionRepo).delete(await session.getMetadata());
|
|
377
|
+
} catch { /* best-effort */ }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Final abort guard (durable cancel TOCTOU) rollback actions: a resumed session
|
|
383
|
+
* moves to its baseline leaf and drops its live entry; a fresh durable session
|
|
384
|
+
* deletes its jsonl. The orchestrator keeps the abort re-check + return inline
|
|
385
|
+
* so no await sits between the re-check and the return (I10); this only runs the
|
|
386
|
+
* rollback body when the guard fires.
|
|
387
|
+
* @param {any} runState
|
|
388
|
+
* @param {{requestedSessionId: string|null, providerSessionId: string, durableRepo: any}} params
|
|
389
|
+
*/
|
|
390
|
+
export async function rollbackAbortedTurn(runState, { requestedSessionId, providerSessionId, durableRepo }) {
|
|
391
|
+
const { session, sessionEntry, baselineLeafId } = runState;
|
|
392
|
+
if (sessionEntry) {
|
|
393
|
+
if (baselineLeafId) {
|
|
394
|
+
try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
|
|
395
|
+
}
|
|
396
|
+
nativeSessions.delete(requestedSessionId);
|
|
397
|
+
} else {
|
|
398
|
+
nativeSessions.delete(providerSessionId);
|
|
399
|
+
try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Outer-catch session cleanup: drop a just-created fresh durable session, drop a
|
|
405
|
+
* create-on-miss reservation placeholder, and roll a resumed session back to its
|
|
406
|
+
* pre-turn leaf for host/runtime-side throws that landed after the harness
|
|
407
|
+
* already mutated the live session.
|
|
408
|
+
* @param {any} runState
|
|
409
|
+
* @param {{durableRepo: any}} params
|
|
410
|
+
*/
|
|
411
|
+
export async function cleanupSessionOnThrow(runState, { durableRepo }) {
|
|
412
|
+
const { session, sessionEntry, reservation, baselineLeafId } = runState;
|
|
413
|
+
// Drop a just-created FRESH durable session so a setup/run failure does not
|
|
414
|
+
// leave a resumable orphan jsonl on disk (the success path drops it via the
|
|
415
|
+
// fresh-run branch; the catch must mirror that). Guarded: `session &&
|
|
416
|
+
// !sessionEntry` fires only for fresh runs that actually created a session —
|
|
417
|
+
// NEVER for resumes (sessionEntry is non-null only on resume; deleting a
|
|
418
|
+
// resumed user session here would be data loss) and never when the throw
|
|
419
|
+
// preceded session create.
|
|
420
|
+
if (session && !sessionEntry) {
|
|
421
|
+
try { await (durableRepo || nativeSessionRepo).delete(await session.getMetadata()); } catch { /* best-effort */ }
|
|
422
|
+
}
|
|
423
|
+
// Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
|
|
424
|
+
// during/after the reservation — including a throw inside the create await
|
|
425
|
+
// itself, where `session` is still null so the jsonl-delete above is skipped.
|
|
426
|
+
// Never set on a resume (sessionEntry would be non-null), so this never
|
|
427
|
+
// deletes a live user session.
|
|
428
|
+
if (reservation && !sessionEntry) reservation.release();
|
|
429
|
+
// Resumed-session rollback for host/runtime-side throws (e.g. a throwing
|
|
430
|
+
// custom pricing resolver / bridge event callback) that land here AFTER the
|
|
431
|
+
// harness already mutated the live session. Mirrors the success-path
|
|
432
|
+
// rollback: move the live session back to the pre-turn leaf so the failed
|
|
433
|
+
// turn never leaks into a later resume. Gated on `sessionEntry &&
|
|
434
|
+
// baselineLeafId` so it only fires for resumes that captured a baseline.
|
|
435
|
+
if (sessionEntry && baselineLeafId) {
|
|
436
|
+
try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Harness event → runtime-event normalization for the pi-native bridge.
|
|
3
|
+
//
|
|
4
|
+
// Pure move of the `harness.subscribe` handler out of pi-native.js: text /
|
|
5
|
+
// thinking delta+end dedup, tool start / update / end / timing events, and the
|
|
6
|
+
// turn-counting + maxTurns stop. All mutable counters and dedup keys live on the
|
|
7
|
+
// caller-owned `runState`; no module-level mutable state here.
|
|
8
|
+
|
|
9
|
+
import { toolResultContent } from "../pi-messages.js";
|
|
10
|
+
import {
|
|
11
|
+
compactToolRawResult,
|
|
12
|
+
eventToolArgs,
|
|
13
|
+
jsonSerializable,
|
|
14
|
+
streamContentKey,
|
|
15
|
+
} from "../pi-events.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The tool-start progress line surfaced as a thinking block, or null for a
|
|
19
|
+
* blank/invalid tool name.
|
|
20
|
+
* @param {unknown} toolName
|
|
21
|
+
* @returns {string|null}
|
|
22
|
+
*/
|
|
23
|
+
export function toolStartProgressText(toolName) {
|
|
24
|
+
if (typeof toolName !== "string" || toolName.trim().length === 0) return null;
|
|
25
|
+
return `Running ${toolName}...`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The slice of run state the stream subscriber reads and mutates. A structural
|
|
30
|
+
* subset of the orchestrator's runState.
|
|
31
|
+
* @typedef {object} StreamSubscriberState
|
|
32
|
+
* @property {string[]} assistantTexts
|
|
33
|
+
* @property {string[]} assistantThinking
|
|
34
|
+
* @property {Set<unknown>} textDeltaIndexes
|
|
35
|
+
* @property {Set<unknown>} thinkingDeltaIndexes
|
|
36
|
+
* @property {Map<string, number>} toolStartTimes
|
|
37
|
+
* @property {number} turnCount
|
|
38
|
+
* @property {number} toolResultsSeen
|
|
39
|
+
* @property {string|null} lastToolName
|
|
40
|
+
* @property {boolean} maxTurnsHit
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the harness subscribe handler. `harness` is passed for the maxTurns
|
|
45
|
+
* abort; it is already constructed when this is wired (subscribe follows the
|
|
46
|
+
* AgentHarness constructor).
|
|
47
|
+
* @param {StreamSubscriberState} runState
|
|
48
|
+
* @param {{onEvent: (event: any) => void, options: any, toolLimits: any, harness: any}} deps
|
|
49
|
+
* @returns {(event: any) => void}
|
|
50
|
+
*/
|
|
51
|
+
export function createStreamSubscriber(runState, { onEvent, options, toolLimits, harness }) {
|
|
52
|
+
return (event) => {
|
|
53
|
+
if (event.type === "message_update") {
|
|
54
|
+
const streamEvent = event.assistantMessageEvent;
|
|
55
|
+
if (streamEvent?.type === "text_delta" && streamEvent.delta) {
|
|
56
|
+
runState.textDeltaIndexes.add(streamContentKey(streamEvent, "text"));
|
|
57
|
+
runState.assistantTexts.push(streamEvent.delta);
|
|
58
|
+
onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.delta }] } });
|
|
59
|
+
} else if (streamEvent?.type === "text_end" && streamEvent.content) {
|
|
60
|
+
const key = streamContentKey(streamEvent, "text");
|
|
61
|
+
if (!runState.textDeltaIndexes.has(key)) {
|
|
62
|
+
runState.assistantTexts.push(streamEvent.content);
|
|
63
|
+
onEvent({ type: "assistant", message: { content: [{ type: "text", text: streamEvent.content }] } });
|
|
64
|
+
}
|
|
65
|
+
} else if (streamEvent?.type === "thinking_delta" && streamEvent.delta) {
|
|
66
|
+
runState.thinkingDeltaIndexes.add(streamContentKey(streamEvent, "thinking"));
|
|
67
|
+
runState.assistantThinking.push(streamEvent.delta);
|
|
68
|
+
onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.delta }] } });
|
|
69
|
+
} else if (streamEvent?.type === "thinking_end" && streamEvent.content) {
|
|
70
|
+
const key = streamContentKey(streamEvent, "thinking");
|
|
71
|
+
if (!runState.thinkingDeltaIndexes.has(key)) {
|
|
72
|
+
runState.assistantThinking.push(streamEvent.content);
|
|
73
|
+
onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: streamEvent.content }] } });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} else if (event.type === "tool_execution_start") {
|
|
77
|
+
if (event.toolName) runState.lastToolName = event.toolName;
|
|
78
|
+
if (event.toolCallId) runState.toolStartTimes.set(event.toolCallId, Date.now());
|
|
79
|
+
const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
|
|
80
|
+
const progressText = toolStartProgressText(event.toolName);
|
|
81
|
+
if (progressText) {
|
|
82
|
+
onEvent({ type: "assistant", message: { content: [{ type: "thinking", text: progressText }] } });
|
|
83
|
+
}
|
|
84
|
+
onEvent({
|
|
85
|
+
type: "assistant",
|
|
86
|
+
message: { content: [{ type: "tool_use", id: event.toolCallId, name: event.toolName, input }] },
|
|
87
|
+
});
|
|
88
|
+
} else if (event.type === "tool_execution_update") {
|
|
89
|
+
const input = eventToolArgs(event.toolName, event.args, { cwd: options.cwd, toolLimits });
|
|
90
|
+
onEvent({
|
|
91
|
+
type: "tool_update",
|
|
92
|
+
tool_use_id: event.toolCallId,
|
|
93
|
+
name: event.toolName,
|
|
94
|
+
input,
|
|
95
|
+
partial_result: jsonSerializable(event.partialResult, String(event.partialResult ?? "")),
|
|
96
|
+
});
|
|
97
|
+
} else if (event.type === "tool_execution_end") {
|
|
98
|
+
const resultContent = toolResultContent(event.result);
|
|
99
|
+
if (!event.isError) runState.toolResultsSeen += 1;
|
|
100
|
+
const startedAt = runState.toolStartTimes.get(event.toolCallId);
|
|
101
|
+
if (startedAt !== undefined) {
|
|
102
|
+
runState.toolStartTimes.delete(event.toolCallId);
|
|
103
|
+
onEvent({
|
|
104
|
+
type: "tool_timing",
|
|
105
|
+
tool_use_id: event.toolCallId,
|
|
106
|
+
name: event.toolName,
|
|
107
|
+
execution_ms: Date.now() - startedAt,
|
|
108
|
+
is_error: !!event.isError,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
onEvent({
|
|
112
|
+
type: "user",
|
|
113
|
+
message: {
|
|
114
|
+
content: [{
|
|
115
|
+
type: "tool_result",
|
|
116
|
+
tool_use_id: event.toolCallId,
|
|
117
|
+
content: resultContent,
|
|
118
|
+
raw_result: compactToolRawResult(jsonSerializable(event.result, resultContent), resultContent),
|
|
119
|
+
is_error: !!event.isError,
|
|
120
|
+
}],
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
} else if (event.type === "turn_end") {
|
|
124
|
+
runState.turnCount += 1;
|
|
125
|
+
// NON-DELEGABLE (verified against @earendil-works/pi-agent-core 0.80.3).
|
|
126
|
+
// pi's only after-turn stop hook is `shouldStopAfterTurn` on the LOW-LEVEL
|
|
127
|
+
// `AgentLoopConfig` (dist/types.d.ts) — the config passed to the raw
|
|
128
|
+
// `agentLoop`. It is NOT surfaced on `AgentHarnessOptions`
|
|
129
|
+
// (dist/harness/types.d.ts) and `AgentHarness` (dist/harness/agent-harness.d.ts)
|
|
130
|
+
// exposes no maxTurns / maxSteps / loop-config passthrough. This bridge is
|
|
131
|
+
// built on AgentHarness (for its session tree, compaction, steering, and
|
|
132
|
+
// event stream); reaching `shouldStopAfterTurn` would mean abandoning the
|
|
133
|
+
// harness for the low-level loop and reimplementing all of that. So the
|
|
134
|
+
// maxTurns ceiling stays enforced HERE: we count `turn_end`s and abort on
|
|
135
|
+
// the one that crosses the ceiling, but only when the turn ended to run
|
|
136
|
+
// MORE tools (stopReason "toolUse") — a turn that already produced a final
|
|
137
|
+
// answer must not be clipped. Delegate to a harness-native option only if
|
|
138
|
+
// pi lifts shouldStopAfterTurn (or an equivalent) onto AgentHarnessOptions.
|
|
139
|
+
if (Number.isFinite(Number(options.maxTurns))
|
|
140
|
+
&& Number(options.maxTurns) > 0
|
|
141
|
+
&& runState.turnCount >= Number(options.maxTurns)
|
|
142
|
+
&& event.message?.stopReason === "toolUse") {
|
|
143
|
+
runState.maxTurnsHit = true;
|
|
144
|
+
harness.abort();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|