@ai-sdk/harness-pi 0.0.0 → 1.0.0-beta.11
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/CHANGELOG.md +95 -0
- package/LICENSE +13 -0
- package/README.md +52 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2178 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -1
- package/src/index.ts +11 -0
- package/src/pi-auth.ts +168 -0
- package/src/pi-events.ts +137 -0
- package/src/pi-harness.ts +145 -0
- package/src/pi-model-resolver.ts +52 -0
- package/src/pi-paths.ts +136 -0
- package/src/pi-remote-ops.ts +293 -0
- package/src/pi-resume-state.ts +81 -0
- package/src/pi-session.ts +1179 -0
- package/src/pi-skills.ts +69 -0
- package/src/pi-translate.ts +331 -0
- package/src/pi-typebox-adapter.ts +11 -0
- package/src/pi-utils.ts +91 -0
- package/src/pi-workspace-mirror.ts +276 -0
- package/src/pi-workspace-vfs.ts +437 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthStorage,
|
|
3
|
+
createAgentSession,
|
|
4
|
+
DefaultResourceLoader,
|
|
5
|
+
defineTool,
|
|
6
|
+
ModelRegistry,
|
|
7
|
+
SessionManager,
|
|
8
|
+
SettingsManager,
|
|
9
|
+
type AgentSession,
|
|
10
|
+
type AgentToolResult,
|
|
11
|
+
type Skill,
|
|
12
|
+
type ToolDefinition,
|
|
13
|
+
} from '@earendil-works/pi-coding-agent';
|
|
14
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { Type } from 'typebox';
|
|
18
|
+
import type {
|
|
19
|
+
HarnessV1ContinueTurnOptions,
|
|
20
|
+
HarnessV1ContinueTurnState,
|
|
21
|
+
HarnessV1PromptControl,
|
|
22
|
+
HarnessV1PromptTurnOptions,
|
|
23
|
+
HarnessV1NetworkSandboxSession,
|
|
24
|
+
HarnessV1PermissionMode,
|
|
25
|
+
HarnessV1ResumeSessionState,
|
|
26
|
+
HarnessV1Session,
|
|
27
|
+
HarnessV1Skill,
|
|
28
|
+
HarnessV1StreamPart,
|
|
29
|
+
HarnessV1ToolSpec,
|
|
30
|
+
} from '@ai-sdk/harness';
|
|
31
|
+
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
32
|
+
import { resolvePiAuth, type PiAuthOptions } from './pi-auth';
|
|
33
|
+
import { getPiTerminalError, parseNativeEvent } from './pi-events';
|
|
34
|
+
import { createPiModelResolver } from './pi-model-resolver';
|
|
35
|
+
import { createPiPathMapper } from './pi-paths';
|
|
36
|
+
import { createPiRemoteOps, type PiRemoteOps } from './pi-remote-ops';
|
|
37
|
+
import { writePiSkills } from './pi-skills';
|
|
38
|
+
import {
|
|
39
|
+
persistSessionFileToSandbox,
|
|
40
|
+
pullSessionFileFromSandbox,
|
|
41
|
+
} from './pi-resume-state';
|
|
42
|
+
import {
|
|
43
|
+
createPiTranslatorState,
|
|
44
|
+
translatePiEvent,
|
|
45
|
+
type PiTranslatorState,
|
|
46
|
+
} from './pi-translate';
|
|
47
|
+
import { toolSpecToTypeBoxParameters } from './pi-typebox-adapter';
|
|
48
|
+
import {
|
|
49
|
+
extractUserText,
|
|
50
|
+
frameInstructions,
|
|
51
|
+
safePiMetadataSegment,
|
|
52
|
+
serializeToolOutput,
|
|
53
|
+
} from './pi-utils';
|
|
54
|
+
import { PiWorkspaceVfs } from './pi-workspace-vfs';
|
|
55
|
+
import { syncHostWorkspaceFromSandbox } from './pi-workspace-mirror';
|
|
56
|
+
|
|
57
|
+
const HARNESS_ID = 'pi';
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* Pi runs in this Node process, not behind an attachable in-sandbox bridge.
|
|
61
|
+
* During a tool approval pause the Pi turn is still alive and blocked on the
|
|
62
|
+
* custom tool promise, so detach must park that live session for the next
|
|
63
|
+
* same-process resume instead of stopping it and resolving the promise as an
|
|
64
|
+
* error. Cross-process resume still falls back to the persisted session file.
|
|
65
|
+
*/
|
|
66
|
+
const parkedPiSessions = new Map<string, HarnessV1Session>();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a discovered resource path belongs to a specific directory.
|
|
70
|
+
*/
|
|
71
|
+
function isWithinDirectory(parent: string, child: string): boolean {
|
|
72
|
+
const rel = path.relative(parent, child);
|
|
73
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a discovered resource path belongs to the session workspace — either
|
|
78
|
+
* the sandbox-side working directory the model sees (`sessionWorkDir`) or its
|
|
79
|
+
* host-side mirror (`hostWorkDir`).
|
|
80
|
+
*/
|
|
81
|
+
function isWithinWorkspace(
|
|
82
|
+
candidate: string,
|
|
83
|
+
sessionWorkDir: string,
|
|
84
|
+
hostWorkDir: string,
|
|
85
|
+
): boolean {
|
|
86
|
+
return (
|
|
87
|
+
isWithinDirectory(sessionWorkDir, candidate) ||
|
|
88
|
+
isWithinDirectory(hostWorkDir, candidate)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createHarnessPiSkills({
|
|
93
|
+
skills,
|
|
94
|
+
sandboxSkillRootDir,
|
|
95
|
+
}: {
|
|
96
|
+
skills: ReadonlyArray<HarnessV1Skill>;
|
|
97
|
+
sandboxSkillRootDir: string;
|
|
98
|
+
}): Skill[] {
|
|
99
|
+
return skills.map(skill => {
|
|
100
|
+
const name = safePiMetadataSegment(skill.name, 'skill');
|
|
101
|
+
const baseDir = path.posix.join(sandboxSkillRootDir, name);
|
|
102
|
+
const filePath = path.posix.join(baseDir, 'SKILL.md');
|
|
103
|
+
return {
|
|
104
|
+
name: skill.name,
|
|
105
|
+
description: skill.description,
|
|
106
|
+
filePath,
|
|
107
|
+
baseDir,
|
|
108
|
+
sourceInfo: {
|
|
109
|
+
path: filePath,
|
|
110
|
+
source: 'harness',
|
|
111
|
+
scope: 'temporary',
|
|
112
|
+
origin: 'top-level',
|
|
113
|
+
baseDir,
|
|
114
|
+
},
|
|
115
|
+
disableModelInvocation: false,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function resolveSandboxHomeDir({
|
|
121
|
+
sandbox,
|
|
122
|
+
abortSignal,
|
|
123
|
+
}: {
|
|
124
|
+
sandbox: Experimental_SandboxSession;
|
|
125
|
+
abortSignal?: AbortSignal;
|
|
126
|
+
}): Promise<string> {
|
|
127
|
+
const result = await sandbox.run({
|
|
128
|
+
command: 'printf "%s" "$HOME"',
|
|
129
|
+
...(abortSignal ? { abortSignal } : {}),
|
|
130
|
+
});
|
|
131
|
+
const homeDir = result.stdout.trim();
|
|
132
|
+
if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return homeDir;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const PI_NATIVE_BUILTIN_NAMES = [
|
|
141
|
+
'read',
|
|
142
|
+
'write',
|
|
143
|
+
'edit',
|
|
144
|
+
'bash',
|
|
145
|
+
'grep',
|
|
146
|
+
'find',
|
|
147
|
+
'ls',
|
|
148
|
+
] as const;
|
|
149
|
+
|
|
150
|
+
const NATIVE_TO_COMMON: Readonly<Record<string, string>> = {
|
|
151
|
+
find: 'glob',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const PI_NATIVE_TOOL_KINDS: Readonly<
|
|
155
|
+
Record<(typeof PI_NATIVE_BUILTIN_NAMES)[number], 'readonly' | 'edit' | 'bash'>
|
|
156
|
+
> = {
|
|
157
|
+
read: 'readonly',
|
|
158
|
+
write: 'edit',
|
|
159
|
+
edit: 'edit',
|
|
160
|
+
bash: 'bash',
|
|
161
|
+
grep: 'readonly',
|
|
162
|
+
find: 'readonly',
|
|
163
|
+
ls: 'readonly',
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export type PiThinkingLevel =
|
|
167
|
+
| 'off'
|
|
168
|
+
| 'minimal'
|
|
169
|
+
| 'low'
|
|
170
|
+
| 'medium'
|
|
171
|
+
| 'high'
|
|
172
|
+
| 'xhigh';
|
|
173
|
+
|
|
174
|
+
export interface PiSessionSettings {
|
|
175
|
+
readonly auth?: PiAuthOptions;
|
|
176
|
+
readonly model?: string;
|
|
177
|
+
readonly thinkingLevel?: PiThinkingLevel;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface CreatePiSessionInput {
|
|
181
|
+
readonly sessionId: string;
|
|
182
|
+
readonly sandboxSession: HarnessV1NetworkSandboxSession;
|
|
183
|
+
readonly sessionWorkDir: string;
|
|
184
|
+
readonly skills: ReadonlyArray<HarnessV1Skill>;
|
|
185
|
+
readonly settings: PiSessionSettings;
|
|
186
|
+
readonly isResume: boolean;
|
|
187
|
+
readonly permissionMode?: HarnessV1PermissionMode;
|
|
188
|
+
readonly resumeSessionFileName?: string;
|
|
189
|
+
readonly abortSignal?: AbortSignal;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface PendingToolResult {
|
|
193
|
+
resolve: (value: unknown) => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface PendingToolApproval {
|
|
197
|
+
resolve: (value: { approved: boolean; reason?: string }) => void;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface ActivePiTurn {
|
|
201
|
+
readonly token: object;
|
|
202
|
+
readonly done: Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function createPiSession(
|
|
206
|
+
input: CreatePiSessionInput,
|
|
207
|
+
): Promise<HarnessV1Session> {
|
|
208
|
+
if (input.isResume) {
|
|
209
|
+
const parkedSession = parkedPiSessions.get(input.sessionId);
|
|
210
|
+
if (parkedSession) {
|
|
211
|
+
parkedPiSessions.delete(input.sessionId);
|
|
212
|
+
return {
|
|
213
|
+
...parkedSession,
|
|
214
|
+
isResume: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Host-side mirror layout under tmpdir. Replace path-separator characters
|
|
220
|
+
// that would otherwise turn a session id like `2026-05-29T17:54:27` into a
|
|
221
|
+
// sub-directory tree on disk.
|
|
222
|
+
const safeSessionId = input.sessionId.replace(/[\\/: ]/g, '-');
|
|
223
|
+
const hostRoot = path.join(tmpdir(), 'ai-sdk-harness', 'pi', safeSessionId);
|
|
224
|
+
const hostWorkDir = path.join(hostRoot, 'workspace');
|
|
225
|
+
const hostAgentDir = path.join(hostRoot, 'agent');
|
|
226
|
+
const hostSessionDir = path.join(hostRoot, 'sessions');
|
|
227
|
+
|
|
228
|
+
// Pi runs in this host process but must behave as though it lives in the
|
|
229
|
+
// sandbox workspace: its working directory is the real `sessionWorkDir`
|
|
230
|
+
// (where `setup()` clones and where the sandbox-backed tools operate), so the
|
|
231
|
+
// paths Pi advertises to the model — most notably the "Current working
|
|
232
|
+
// directory" line in its system prompt — resolve inside the sandbox. The
|
|
233
|
+
// workspace VFS maps that sandbox path to the host-side mirror so Pi's own
|
|
234
|
+
// `fs`-based resource loading (`.pi/`, `AGENTS.md`) still works on the host.
|
|
235
|
+
// `sessionWorkDir` is a sandbox path (e.g. `/vercel/sandbox/...`) that does
|
|
236
|
+
// not exist on the host, so it is a safe, collision-free VFS mount point.
|
|
237
|
+
const sessionWorkDir = input.sessionWorkDir;
|
|
238
|
+
|
|
239
|
+
await mkdir(hostWorkDir, { recursive: true });
|
|
240
|
+
await mkdir(hostAgentDir, { recursive: true });
|
|
241
|
+
await mkdir(hostSessionDir, { recursive: true });
|
|
242
|
+
|
|
243
|
+
const sandbox = input.sandboxSession.restricted();
|
|
244
|
+
const permissionMode = input.permissionMode ?? 'allow-all';
|
|
245
|
+
let sandboxSkillRootDir: string | undefined;
|
|
246
|
+
let harnessSkills: Skill[] = [];
|
|
247
|
+
|
|
248
|
+
// Materialise harness-provided skills into sandbox HOME, not the workspace.
|
|
249
|
+
if (input.skills.length > 0) {
|
|
250
|
+
const sandboxHomeDir = await resolveSandboxHomeDir({
|
|
251
|
+
sandbox,
|
|
252
|
+
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
|
|
253
|
+
});
|
|
254
|
+
sandboxSkillRootDir = path.posix.join(sandboxHomeDir, '.agents', 'skills');
|
|
255
|
+
harnessSkills = createHarnessPiSkills({
|
|
256
|
+
skills: input.skills,
|
|
257
|
+
sandboxSkillRootDir,
|
|
258
|
+
});
|
|
259
|
+
await writePiSkills({
|
|
260
|
+
sandbox,
|
|
261
|
+
sandboxHomeDir,
|
|
262
|
+
skills: input.skills,
|
|
263
|
+
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// On resume: pull the Pi session file out of the sandbox into the fresh
|
|
268
|
+
// host mirror so SessionManager.open can read it.
|
|
269
|
+
let resumeSessionFilePath: string | undefined;
|
|
270
|
+
if (input.isResume && input.resumeSessionFileName) {
|
|
271
|
+
resumeSessionFilePath = await pullSessionFileFromSandbox({
|
|
272
|
+
sandbox,
|
|
273
|
+
sessionWorkDir: input.sessionWorkDir,
|
|
274
|
+
hostSessionDir,
|
|
275
|
+
sessionFileName: input.resumeSessionFileName,
|
|
276
|
+
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Snapshot sandbox state into the host mirror BEFORE the VFS goes live so
|
|
281
|
+
// Pi sees the workspace as soon as it boots.
|
|
282
|
+
await syncHostWorkspaceFromSandbox({
|
|
283
|
+
sandbox,
|
|
284
|
+
sandboxWorkDir: input.sessionWorkDir,
|
|
285
|
+
hostWorkDir,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Mount only the workspace: the model's view of the workspace lives at
|
|
289
|
+
// `sessionWorkDir` and is backed by `hostWorkDir`. The agent and session
|
|
290
|
+
// directories stay on the real host filesystem (below) — they are host-only
|
|
291
|
+
// Pi state (auth, model registry, session journal) that must never surface
|
|
292
|
+
// in the sandbox or the workspace mirror.
|
|
293
|
+
const workspaceVfs = new PiWorkspaceVfs();
|
|
294
|
+
workspaceVfs.mount(hostWorkDir, sessionWorkDir);
|
|
295
|
+
|
|
296
|
+
const paths = createPiPathMapper({
|
|
297
|
+
hostWorkDir,
|
|
298
|
+
sandboxWorkDir: sessionWorkDir,
|
|
299
|
+
readableRoots: sandboxSkillRootDir
|
|
300
|
+
? [{ sandboxDir: sandboxSkillRootDir }]
|
|
301
|
+
: [],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Pi auth + model registry are global to this Pi session. These live on the
|
|
305
|
+
// real host filesystem (`hostAgentDir`), never in the sandbox/workspace.
|
|
306
|
+
const authStorage = AuthStorage.create(path.join(hostAgentDir, 'auth.json'));
|
|
307
|
+
const modelRegistry = ModelRegistry.create(
|
|
308
|
+
authStorage,
|
|
309
|
+
path.join(hostAgentDir, 'models.json'),
|
|
310
|
+
);
|
|
311
|
+
const settingsManager = SettingsManager.inMemory();
|
|
312
|
+
|
|
313
|
+
// Run-scoped env (for the model resolver's gateway fallback heuristic).
|
|
314
|
+
const resolverEnv = resolvePiAuth(input.settings.auth, process.env, {
|
|
315
|
+
authStorage,
|
|
316
|
+
modelRegistry,
|
|
317
|
+
});
|
|
318
|
+
const resolveModel = createPiModelResolver(modelRegistry, resolverEnv);
|
|
319
|
+
// Resolve once: deterministic given the configured model. This is the Pi
|
|
320
|
+
// `Model` object handed to `createAgentSession`.
|
|
321
|
+
const resolvedModel = resolveModel(input.settings.model);
|
|
322
|
+
|
|
323
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
324
|
+
cwd: sessionWorkDir,
|
|
325
|
+
agentDir: hostAgentDir,
|
|
326
|
+
settingsManager,
|
|
327
|
+
appendSystemPromptOverride: () => [],
|
|
328
|
+
extensionFactories: [],
|
|
329
|
+
// Pi runs in the host process, so its default resource discovery reaches
|
|
330
|
+
// the host developer's personal config (`~/.pi/agent/*`, `~/.agents/*`).
|
|
331
|
+
// The harness does not expose extensions, themes, or prompt templates, so
|
|
332
|
+
// disable those entirely — this also avoids loading and executing a host
|
|
333
|
+
// developer's personal Pi extensions inside the server process. Skills are
|
|
334
|
+
// kept but filtered to workspace project skills plus harness-provided
|
|
335
|
+
// skills whose files live in sandbox HOME.
|
|
336
|
+
noExtensions: true,
|
|
337
|
+
noThemes: true,
|
|
338
|
+
noPromptTemplates: true,
|
|
339
|
+
skillsOverride: base => ({
|
|
340
|
+
...base,
|
|
341
|
+
skills: [
|
|
342
|
+
...base.skills.filter(skill =>
|
|
343
|
+
isWithinWorkspace(skill.filePath, sessionWorkDir, hostWorkDir),
|
|
344
|
+
),
|
|
345
|
+
...harnessSkills,
|
|
346
|
+
],
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
await resourceLoader.reload();
|
|
350
|
+
|
|
351
|
+
// Per-session mutable state we hold across prompts.
|
|
352
|
+
let piSession: AgentSession | undefined;
|
|
353
|
+
let unsubscribe: (() => void) | undefined;
|
|
354
|
+
let lastToolsSignature: string | undefined;
|
|
355
|
+
let sessionFileName: string | undefined;
|
|
356
|
+
let stopped = false;
|
|
357
|
+
/*
|
|
358
|
+
* Set by `doSuspendTurn` before it aborts the in-flight host turn at a slice
|
|
359
|
+
* boundary. The turn's catch settles silently when this is set, so the stream
|
|
360
|
+
* closes cleanly (no spurious `error` chunk) — the next slice rerun-continues
|
|
361
|
+
* from the persisted journal.
|
|
362
|
+
*/
|
|
363
|
+
let suspending = false;
|
|
364
|
+
/*
|
|
365
|
+
* Instructions are prepended to the first user message of a fresh session
|
|
366
|
+
* only. A resumed session already carried them in its original first
|
|
367
|
+
* message (preserved in the persisted session file), so it starts "applied".
|
|
368
|
+
*/
|
|
369
|
+
let instructionsApplied = input.isResume;
|
|
370
|
+
const pendingToolResults = new Map<string, PendingToolResult>();
|
|
371
|
+
const pendingToolApprovals = new Map<string, PendingToolApproval>();
|
|
372
|
+
|
|
373
|
+
// Emit channel set at the start of every doPromptTurn and cleared on end.
|
|
374
|
+
let currentEmit: ((part: HarnessV1StreamPart) => void) | undefined;
|
|
375
|
+
let translatorState: PiTranslatorState | undefined;
|
|
376
|
+
let activeTurn: ActivePiTurn | undefined;
|
|
377
|
+
/*
|
|
378
|
+
* Compaction parts produced while no turn is active. Pi's `compact()` aborts
|
|
379
|
+
* the current turn before it summarizes, so a manually triggered compaction
|
|
380
|
+
* (and any compaction that lands between turns) emits its `compaction_end`
|
|
381
|
+
* after `currentEmit` has been cleared. Buffer those parts and flush them on
|
|
382
|
+
* the next turn's stream so the observation is not lost. Auto-compaction that
|
|
383
|
+
* runs mid-turn still emits inline via `currentEmit`.
|
|
384
|
+
*/
|
|
385
|
+
const pendingCompactionParts: HarnessV1StreamPart[] = [];
|
|
386
|
+
|
|
387
|
+
const remoteOps = createPiRemoteOps({
|
|
388
|
+
sandbox,
|
|
389
|
+
paths,
|
|
390
|
+
onFileChange: (event, relPath) => {
|
|
391
|
+
currentEmit?.({ type: 'file-change', event, path: relPath });
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
function settlePendingToolResults(reason: string): void {
|
|
396
|
+
for (const pending of pendingToolResults.values()) {
|
|
397
|
+
pending.resolve({ error: reason });
|
|
398
|
+
}
|
|
399
|
+
pendingToolResults.clear();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function settlePendingToolApprovals(reason: string): void {
|
|
403
|
+
for (const pending of pendingToolApprovals.values()) {
|
|
404
|
+
pending.resolve({ approved: false, reason });
|
|
405
|
+
}
|
|
406
|
+
pendingToolApprovals.clear();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function persistSessionFile(): Promise<void> {
|
|
410
|
+
if (!sessionFileName) return;
|
|
411
|
+
await persistSessionFileToSandbox({
|
|
412
|
+
sandbox,
|
|
413
|
+
sessionWorkDir: input.sessionWorkDir,
|
|
414
|
+
hostSessionDir,
|
|
415
|
+
sessionFileName,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function createPromptControl(input: {
|
|
420
|
+
done: Promise<void>;
|
|
421
|
+
abortSignal?: AbortSignal;
|
|
422
|
+
}): HarnessV1PromptControl {
|
|
423
|
+
const abortHandler = () => {
|
|
424
|
+
piSession?.abort().catch(() => {});
|
|
425
|
+
};
|
|
426
|
+
if (input.abortSignal) {
|
|
427
|
+
input.abortSignal.addEventListener('abort', abortHandler, {
|
|
428
|
+
once: true,
|
|
429
|
+
});
|
|
430
|
+
void input.done.then(
|
|
431
|
+
() => {
|
|
432
|
+
input.abortSignal?.removeEventListener('abort', abortHandler);
|
|
433
|
+
},
|
|
434
|
+
() => {
|
|
435
|
+
input.abortSignal?.removeEventListener('abort', abortHandler);
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
async submitToolResult(args) {
|
|
442
|
+
const pending = pendingToolResults.get(args.toolCallId);
|
|
443
|
+
if (!pending) return;
|
|
444
|
+
pendingToolResults.delete(args.toolCallId);
|
|
445
|
+
/*
|
|
446
|
+
* Preserve the original output so the result projection can surface it
|
|
447
|
+
* unchanged. The tool handler stringifies the output for the runtime
|
|
448
|
+
* (so the model reads it), and Pi echoes that text back — without this
|
|
449
|
+
* the consumer-facing result would be the serialized string instead of
|
|
450
|
+
* the original object.
|
|
451
|
+
*/
|
|
452
|
+
translatorState?.hostToolResults.set(args.toolCallId, args.output);
|
|
453
|
+
pending.resolve(args.output);
|
|
454
|
+
},
|
|
455
|
+
async submitToolApproval(args) {
|
|
456
|
+
const pending = pendingToolApprovals.get(args.approvalId);
|
|
457
|
+
if (!pending) return;
|
|
458
|
+
pendingToolApprovals.delete(args.approvalId);
|
|
459
|
+
pending.resolve({
|
|
460
|
+
approved: args.approved,
|
|
461
|
+
reason: args.reason,
|
|
462
|
+
});
|
|
463
|
+
},
|
|
464
|
+
async submitUserMessage(text) {
|
|
465
|
+
await piSession?.steer(text);
|
|
466
|
+
},
|
|
467
|
+
done: input.done,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function requestBuiltinToolApproval(args: {
|
|
472
|
+
toolCallId: string;
|
|
473
|
+
nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
|
|
474
|
+
}): Promise<{ approved: boolean; reason?: string }> {
|
|
475
|
+
if (
|
|
476
|
+
!piBuiltinToolRequiresApproval({
|
|
477
|
+
permissionMode,
|
|
478
|
+
kind: PI_NATIVE_TOOL_KINDS[args.nativeName],
|
|
479
|
+
})
|
|
480
|
+
) {
|
|
481
|
+
return { approved: true };
|
|
482
|
+
}
|
|
483
|
+
currentEmit?.({
|
|
484
|
+
type: 'tool-approval-request',
|
|
485
|
+
approvalId: args.toolCallId,
|
|
486
|
+
toolCallId: args.toolCallId,
|
|
487
|
+
});
|
|
488
|
+
return new Promise(resolve => {
|
|
489
|
+
pendingToolApprovals.set(args.toolCallId, { resolve });
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function buildToolDefinitions(userTools: ReadonlyArray<HarnessV1ToolSpec>): {
|
|
494
|
+
customTools: ToolDefinition[];
|
|
495
|
+
builtinNames: string[];
|
|
496
|
+
} {
|
|
497
|
+
const customTools: ToolDefinition[] = [
|
|
498
|
+
...PI_NATIVE_BUILTIN_NAMES.map(native =>
|
|
499
|
+
buildBuiltinToolDefinition({
|
|
500
|
+
native,
|
|
501
|
+
remoteOps,
|
|
502
|
+
requestApproval: requestBuiltinToolApproval,
|
|
503
|
+
}),
|
|
504
|
+
),
|
|
505
|
+
...userTools.map(spec =>
|
|
506
|
+
buildUserToolDefinition(spec, pendingToolResults),
|
|
507
|
+
),
|
|
508
|
+
];
|
|
509
|
+
return {
|
|
510
|
+
customTools,
|
|
511
|
+
builtinNames: [...PI_NATIVE_BUILTIN_NAMES],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function rebuildPiSession(
|
|
516
|
+
userTools: ReadonlyArray<HarnessV1ToolSpec>,
|
|
517
|
+
isFirstBuild: boolean,
|
|
518
|
+
): Promise<void> {
|
|
519
|
+
if (piSession) {
|
|
520
|
+
unsubscribe?.();
|
|
521
|
+
unsubscribe = undefined;
|
|
522
|
+
piSession.dispose();
|
|
523
|
+
piSession = undefined;
|
|
524
|
+
// Original adapter waits 25 ms here to let Pi's teardown microtasks
|
|
525
|
+
// settle before the next createAgentSession. Port verbatim.
|
|
526
|
+
// TODO(pi-0.77): verify the race still exists; original SDK had a
|
|
527
|
+
// teardown microtask the host needed to wait on.
|
|
528
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const { customTools, builtinNames } = buildToolDefinitions(userTools);
|
|
532
|
+
const toolNames = customTools.map(t => t.name);
|
|
533
|
+
|
|
534
|
+
// SessionManager: open the resumed file on the first build of a resumed
|
|
535
|
+
// session; create fresh otherwise.
|
|
536
|
+
const sessionManager =
|
|
537
|
+
isFirstBuild && resumeSessionFilePath
|
|
538
|
+
? SessionManager.open(
|
|
539
|
+
resumeSessionFilePath,
|
|
540
|
+
hostSessionDir,
|
|
541
|
+
sessionWorkDir,
|
|
542
|
+
)
|
|
543
|
+
: SessionManager.create(sessionWorkDir, hostSessionDir);
|
|
544
|
+
|
|
545
|
+
const { session } = await createAgentSession({
|
|
546
|
+
cwd: sessionWorkDir,
|
|
547
|
+
agentDir: hostAgentDir,
|
|
548
|
+
authStorage,
|
|
549
|
+
modelRegistry,
|
|
550
|
+
sessionManager,
|
|
551
|
+
settingsManager,
|
|
552
|
+
resourceLoader,
|
|
553
|
+
customTools,
|
|
554
|
+
tools: toolNames,
|
|
555
|
+
...(input.settings.thinkingLevel
|
|
556
|
+
? { thinkingLevel: input.settings.thinkingLevel }
|
|
557
|
+
: {}),
|
|
558
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
559
|
+
});
|
|
560
|
+
piSession = session;
|
|
561
|
+
|
|
562
|
+
// Pick up the actual session file path so doStop can persist it. Pi
|
|
563
|
+
// 0.77 emits `.jsonl` files; older builds used `.json`. Persist the
|
|
564
|
+
// basename verbatim — including the extension — so the resume path can
|
|
565
|
+
// round-trip it without guessing the extension.
|
|
566
|
+
const candidatePath = sessionManager.getSessionFile();
|
|
567
|
+
if (candidatePath) {
|
|
568
|
+
sessionFileName = path.basename(candidatePath);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
translatorState = createPiTranslatorState({
|
|
572
|
+
builtinToolNames: builtinNames,
|
|
573
|
+
nativeToCommon: NATIVE_TO_COMMON,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
unsubscribe = piSession.subscribe(rawEvent => {
|
|
577
|
+
if (!translatorState) return;
|
|
578
|
+
const event = parseNativeEvent(rawEvent);
|
|
579
|
+
if (!event) return;
|
|
580
|
+
for (const part of translatePiEvent(event, translatorState)) {
|
|
581
|
+
if (currentEmit) {
|
|
582
|
+
currentEmit(part);
|
|
583
|
+
} else if (part.type === 'compaction') {
|
|
584
|
+
// No active turn: defer compaction observations to the next turn.
|
|
585
|
+
pendingCompactionParts.push(part);
|
|
586
|
+
}
|
|
587
|
+
// Other event types outside a turn have no consumer and are dropped.
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/*
|
|
593
|
+
* Drive one turn against the Pi session and return the control surface.
|
|
594
|
+
* Shared by `doPromptTurn` (a fresh user prompt) and `doContinueTurn` (an empty
|
|
595
|
+
* prompt that asks Pi to continue its own thread after a rerun resume).
|
|
596
|
+
*/
|
|
597
|
+
async function runTurn(turnOpts: {
|
|
598
|
+
text: string;
|
|
599
|
+
tools: ReadonlyArray<HarnessV1ToolSpec>;
|
|
600
|
+
emit: (part: HarnessV1StreamPart) => void;
|
|
601
|
+
abortSignal?: AbortSignal;
|
|
602
|
+
}): Promise<HarnessV1PromptControl> {
|
|
603
|
+
if (stopped) {
|
|
604
|
+
throw new Error('Pi session has been stopped.');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const userTools = turnOpts.tools;
|
|
608
|
+
const signature = JSON.stringify(userTools.map(t => t.name).sort());
|
|
609
|
+
const needsRebuild = piSession == null || signature !== lastToolsSignature;
|
|
610
|
+
if (needsRebuild) {
|
|
611
|
+
await rebuildPiSession(userTools, piSession == null);
|
|
612
|
+
lastToolsSignature = signature;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
await resourceLoader.reload();
|
|
616
|
+
await syncHostWorkspaceFromSandbox({
|
|
617
|
+
sandbox,
|
|
618
|
+
sandboxWorkDir: input.sessionWorkDir,
|
|
619
|
+
hostWorkDir,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
currentEmit = turnOpts.emit;
|
|
623
|
+
// Fresh translator state for the new turn — keep the tool sets the
|
|
624
|
+
// session was built with.
|
|
625
|
+
translatorState = createPiTranslatorState({
|
|
626
|
+
builtinToolNames: [...PI_NATIVE_BUILTIN_NAMES],
|
|
627
|
+
nativeToCommon: NATIVE_TO_COMMON,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
turnOpts.emit({ type: 'stream-start' });
|
|
631
|
+
|
|
632
|
+
const turnPromise = (async () => {
|
|
633
|
+
let terminalError: string | undefined;
|
|
634
|
+
const session = piSession!;
|
|
635
|
+
|
|
636
|
+
// We subscribed in rebuild, but the translator may need to detect
|
|
637
|
+
// terminal errors too — wrap a second listener that records them.
|
|
638
|
+
const unsubErr = session.subscribe(raw => {
|
|
639
|
+
const ev = parseNativeEvent(raw);
|
|
640
|
+
if (!ev) return;
|
|
641
|
+
const err = getPiTerminalError(ev);
|
|
642
|
+
if (err && !terminalError) {
|
|
643
|
+
terminalError = err;
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
await session.prompt(turnOpts.text);
|
|
649
|
+
|
|
650
|
+
if (terminalError) {
|
|
651
|
+
/*
|
|
652
|
+
* A `doSuspendTurn` aborts the in-flight turn on purpose. Pi surfaces
|
|
653
|
+
* that abort as a *resolved* prompt with a recorded terminal error
|
|
654
|
+
* ("This operation was aborted") rather than a thrown exception, so the
|
|
655
|
+
* `catch` guard below never sees it. Swallow it here too — but only if
|
|
656
|
+
* it's actually the abort: the stream then closes cleanly (no spurious
|
|
657
|
+
* `error` chunk) and the next slice rerun-continues from the journal.
|
|
658
|
+
* Any other terminal error mid-suspend is unanticipated and must
|
|
659
|
+
* surface.
|
|
660
|
+
*/
|
|
661
|
+
if (suspending && isAbortError(terminalError)) return;
|
|
662
|
+
currentEmit?.({ type: 'error', error: new Error(terminalError) });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const stats = session.getSessionStats();
|
|
667
|
+
const finishReason = {
|
|
668
|
+
unified: 'stop' as const,
|
|
669
|
+
raw: undefined,
|
|
670
|
+
};
|
|
671
|
+
const usage = {
|
|
672
|
+
inputTokens: {
|
|
673
|
+
total: stats.tokens.input,
|
|
674
|
+
noCache: undefined,
|
|
675
|
+
cacheRead: stats.tokens.cacheRead,
|
|
676
|
+
cacheWrite: stats.tokens.cacheWrite,
|
|
677
|
+
},
|
|
678
|
+
outputTokens: {
|
|
679
|
+
total: stats.tokens.output,
|
|
680
|
+
text: undefined,
|
|
681
|
+
reasoning: undefined,
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
// `finish-step` populates the step's finishReason + usage (the
|
|
685
|
+
// agent's result builder reads this); `finish` marks the turn end
|
|
686
|
+
// with totalUsage.
|
|
687
|
+
currentEmit?.({ type: 'finish-step', finishReason, usage });
|
|
688
|
+
currentEmit?.({
|
|
689
|
+
type: 'finish',
|
|
690
|
+
finishReason,
|
|
691
|
+
totalUsage: usage,
|
|
692
|
+
});
|
|
693
|
+
} catch (err) {
|
|
694
|
+
// A `doSuspendTurn` aborts the in-flight turn on purpose — settle silently
|
|
695
|
+
// so the stream closes cleanly without a spurious `error` chunk; the
|
|
696
|
+
// next slice rerun-continues from the persisted journal.
|
|
697
|
+
// Same rule as the resolved-with-terminalError path: only swallow the
|
|
698
|
+
// abort our own suspend caused; surface anything unanticipated.
|
|
699
|
+
if (suspending && isAbortError(err)) return;
|
|
700
|
+
currentEmit?.({ type: 'error', error: err });
|
|
701
|
+
} finally {
|
|
702
|
+
unsubErr();
|
|
703
|
+
}
|
|
704
|
+
})();
|
|
705
|
+
|
|
706
|
+
const activeTurnToken = {};
|
|
707
|
+
const done = turnPromise.finally(() => {
|
|
708
|
+
if (activeTurn?.token === activeTurnToken) {
|
|
709
|
+
activeTurn = undefined;
|
|
710
|
+
}
|
|
711
|
+
currentEmit = undefined;
|
|
712
|
+
});
|
|
713
|
+
activeTurn = {
|
|
714
|
+
token: activeTurnToken,
|
|
715
|
+
done,
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
return createPromptControl({
|
|
719
|
+
done,
|
|
720
|
+
abortSignal: turnOpts.abortSignal,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const doStop = async (): Promise<HarnessV1ResumeSessionState> => {
|
|
725
|
+
if (stopped) {
|
|
726
|
+
throw new Error('Pi session has been stopped.');
|
|
727
|
+
}
|
|
728
|
+
stopped = true;
|
|
729
|
+
parkedPiSessions.delete(input.sessionId);
|
|
730
|
+
settlePendingToolResults('Pi session stopped');
|
|
731
|
+
settlePendingToolApprovals('Pi session stopped');
|
|
732
|
+
|
|
733
|
+
// Persist the Pi session file into the sandbox so a future process
|
|
734
|
+
// can pick it up after `provider.resumeSession({ sessionId })` reattaches.
|
|
735
|
+
if (sessionFileName) {
|
|
736
|
+
try {
|
|
737
|
+
await persistSessionFile();
|
|
738
|
+
} catch {
|
|
739
|
+
// Best-effort: a missing session file means resume returns to a
|
|
740
|
+
// fresh conversation rather than failing stop.
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
unsubscribe?.();
|
|
745
|
+
unsubscribe = undefined;
|
|
746
|
+
piSession?.dispose();
|
|
747
|
+
piSession = undefined;
|
|
748
|
+
workspaceVfs.unmount();
|
|
749
|
+
await rm(hostRoot, { recursive: true, force: true });
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
type: 'resume-session',
|
|
753
|
+
harnessId: HARNESS_ID,
|
|
754
|
+
specificationVersion: 'harness-v1',
|
|
755
|
+
data: sessionFileName ? { sessionFileName } : {},
|
|
756
|
+
};
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const sessionImpl: HarnessV1Session = {
|
|
760
|
+
sessionId: input.sessionId,
|
|
761
|
+
isResume: input.isResume,
|
|
762
|
+
// The model Pi actually resolves to (the configured id, or its default when
|
|
763
|
+
// unset) — `gen_ai.request.model`.
|
|
764
|
+
...(resolvedModel?.id ? { modelId: resolvedModel.id } : {}),
|
|
765
|
+
|
|
766
|
+
// Pi has no bridge to attach to and no on-disk event log to replay; its
|
|
767
|
+
// only resume path is restoring the session file on a fresh/snapshotted
|
|
768
|
+
// sandbox, i.e. `rerun`.
|
|
769
|
+
|
|
770
|
+
doPromptTurn: async (
|
|
771
|
+
promptOpts: HarnessV1PromptTurnOptions,
|
|
772
|
+
): Promise<HarnessV1PromptControl> => {
|
|
773
|
+
let text = extractUserText(promptOpts.prompt);
|
|
774
|
+
if (!instructionsApplied && promptOpts.instructions) {
|
|
775
|
+
text = frameInstructions(promptOpts.instructions, text);
|
|
776
|
+
}
|
|
777
|
+
instructionsApplied = true;
|
|
778
|
+
|
|
779
|
+
return runTurn({
|
|
780
|
+
text,
|
|
781
|
+
tools: promptOpts.tools ?? [],
|
|
782
|
+
emit: promptOpts.emit,
|
|
783
|
+
abortSignal: promptOpts.abortSignal,
|
|
784
|
+
});
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
doContinueTurn: async (
|
|
788
|
+
continueOpts: HarnessV1ContinueTurnOptions,
|
|
789
|
+
): Promise<HarnessV1PromptControl> => {
|
|
790
|
+
if (activeTurn != null) {
|
|
791
|
+
currentEmit = continueOpts.emit;
|
|
792
|
+
return createPromptControl({
|
|
793
|
+
done: activeTurn.done,
|
|
794
|
+
abortSignal: continueOpts.abortSignal,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/*
|
|
799
|
+
* Pi runs the model on the host, so there is no live turn in the sandbox
|
|
800
|
+
* to attach to — the previous slice's turn died with its process.
|
|
801
|
+
* Rerun-continue: re-drive the agent from the journal restored on resume.
|
|
802
|
+
* An empty prompt asks Pi to continue its own thread. Lossy — any work in
|
|
803
|
+
* flight at the slice boundary is recomputed because a host-resident
|
|
804
|
+
* runtime cannot do a lossless attach.
|
|
805
|
+
*/
|
|
806
|
+
return runTurn({
|
|
807
|
+
text: '',
|
|
808
|
+
tools: continueOpts.tools ?? [],
|
|
809
|
+
emit: continueOpts.emit,
|
|
810
|
+
abortSignal: continueOpts.abortSignal,
|
|
811
|
+
});
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
doCompact: async (customInstructions?: string) => {
|
|
815
|
+
if (stopped) {
|
|
816
|
+
throw new Error('Pi session has been stopped.');
|
|
817
|
+
}
|
|
818
|
+
/*
|
|
819
|
+
* Pi owns the compaction. We just request it; the resulting
|
|
820
|
+
* `compaction_end` event is observed by the session subscription and
|
|
821
|
+
* translated into a `compaction` stream part. The returned
|
|
822
|
+
* `CompactionResult` is intentionally discarded here.
|
|
823
|
+
*/
|
|
824
|
+
await piSession?.compact(customInstructions);
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
doDestroy: async () => {
|
|
828
|
+
if (stopped) return;
|
|
829
|
+
stopped = true;
|
|
830
|
+
parkedPiSessions.delete(input.sessionId);
|
|
831
|
+
settlePendingToolResults('Pi session stopped');
|
|
832
|
+
settlePendingToolApprovals('Pi session stopped');
|
|
833
|
+
unsubscribe?.();
|
|
834
|
+
unsubscribe = undefined;
|
|
835
|
+
piSession?.dispose();
|
|
836
|
+
piSession = undefined;
|
|
837
|
+
workspaceVfs.unmount();
|
|
838
|
+
await rm(hostRoot, { recursive: true, force: true });
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
doStop,
|
|
842
|
+
|
|
843
|
+
doDetach: async (): Promise<HarnessV1ResumeSessionState> => {
|
|
844
|
+
if (activeTurn != null || pendingToolResults.size > 0) {
|
|
845
|
+
parkedPiSessions.set(input.sessionId, sessionImpl);
|
|
846
|
+
if (sessionFileName) {
|
|
847
|
+
try {
|
|
848
|
+
await persistSessionFile();
|
|
849
|
+
} catch {
|
|
850
|
+
/*
|
|
851
|
+
* The parked in-process session is the authoritative continuation
|
|
852
|
+
* path while the live turn is waiting on host input. Persistence is
|
|
853
|
+
* only a fallback for later non-live resumes.
|
|
854
|
+
*/
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
type: 'resume-session',
|
|
859
|
+
harnessId: HARNESS_ID,
|
|
860
|
+
specificationVersion: 'harness-v1',
|
|
861
|
+
data: sessionFileName ? { sessionFileName } : {},
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
return doStop();
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
doSuspendTurn: async (): Promise<HarnessV1ContinueTurnState> => {
|
|
868
|
+
if (stopped) {
|
|
869
|
+
throw new Error('Pi session has been stopped.');
|
|
870
|
+
}
|
|
871
|
+
/*
|
|
872
|
+
* Pi's model runs in this host process, which is about to be suspended at
|
|
873
|
+
* the slice boundary — the in-flight turn cannot survive it. Abort it (the
|
|
874
|
+
* turn settles silently via the `suspending` guard so the stream closes
|
|
875
|
+
* cleanly), persist the journal into the sandbox, and tear down host-side
|
|
876
|
+
* resources. The sandbox itself is left running; the next slice pulls the
|
|
877
|
+
* journal after `provider.resumeSession({ sessionId })` and rerun-continues. The
|
|
878
|
+
* tail in flight at the boundary is recomputed — Pi cannot freeze a live
|
|
879
|
+
* turn the way a bridge adapter can.
|
|
880
|
+
*/
|
|
881
|
+
suspending = true;
|
|
882
|
+
await Promise.resolve(piSession?.abort()).catch(() => {});
|
|
883
|
+
|
|
884
|
+
if (sessionFileName) {
|
|
885
|
+
try {
|
|
886
|
+
await persistSessionFile();
|
|
887
|
+
} catch {
|
|
888
|
+
// Best-effort: a missing/failed copy leaves the previously persisted
|
|
889
|
+
// journal in place, so the next slice resumes from a slightly older
|
|
890
|
+
// (still valid) state.
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
stopped = true;
|
|
895
|
+
parkedPiSessions.delete(input.sessionId);
|
|
896
|
+
settlePendingToolResults('Pi session suspended');
|
|
897
|
+
settlePendingToolApprovals('Pi session suspended');
|
|
898
|
+
unsubscribe?.();
|
|
899
|
+
unsubscribe = undefined;
|
|
900
|
+
piSession?.dispose();
|
|
901
|
+
piSession = undefined;
|
|
902
|
+
workspaceVfs.unmount();
|
|
903
|
+
await rm(hostRoot, { recursive: true, force: true });
|
|
904
|
+
|
|
905
|
+
return {
|
|
906
|
+
type: 'continue-turn',
|
|
907
|
+
harnessId: HARNESS_ID,
|
|
908
|
+
specificationVersion: 'harness-v1',
|
|
909
|
+
data: sessionFileName ? { sessionFileName } : {},
|
|
910
|
+
};
|
|
911
|
+
},
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
return sessionImpl;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Whether a terminal error (string from Pi's event stream, or a thrown error)
|
|
919
|
+
* is an abort — the expected result of `doSuspendTurn` aborting the in-flight
|
|
920
|
+
* turn. Only these are safe to swallow while `suspending`; any other error is
|
|
921
|
+
* unanticipated and must surface as an `error` chunk.
|
|
922
|
+
*/
|
|
923
|
+
function isAbortError(value: unknown): boolean {
|
|
924
|
+
if (value == null) return false;
|
|
925
|
+
if (
|
|
926
|
+
typeof value === 'object' &&
|
|
927
|
+
(value as { name?: unknown }).name === 'AbortError'
|
|
928
|
+
) {
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
const text =
|
|
932
|
+
typeof value === 'string'
|
|
933
|
+
? value
|
|
934
|
+
: value instanceof Error
|
|
935
|
+
? value.message
|
|
936
|
+
: String(value);
|
|
937
|
+
return /\baborted\b|AbortError|operation was aborted/i.test(text);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function asPiToolResult(text: string): AgentToolResult<unknown> {
|
|
941
|
+
return {
|
|
942
|
+
content: [{ type: 'text', text }],
|
|
943
|
+
details: undefined,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function maybeDenyPiBuiltinTool(input: {
|
|
948
|
+
toolCallId: string;
|
|
949
|
+
nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
|
|
950
|
+
requestApproval: (args: {
|
|
951
|
+
toolCallId: string;
|
|
952
|
+
nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
|
|
953
|
+
}) => Promise<{ approved: boolean; reason?: string }>;
|
|
954
|
+
}): Promise<AgentToolResult<unknown> | undefined> {
|
|
955
|
+
const decision = await input.requestApproval({
|
|
956
|
+
toolCallId: input.toolCallId,
|
|
957
|
+
nativeName: input.nativeName,
|
|
958
|
+
});
|
|
959
|
+
if (decision.approved) return undefined;
|
|
960
|
+
return asPiToolResult(
|
|
961
|
+
serializeToolOutput({
|
|
962
|
+
type: 'execution-denied',
|
|
963
|
+
reason: decision.reason,
|
|
964
|
+
}),
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function piBuiltinToolRequiresApproval(input: {
|
|
969
|
+
permissionMode: HarnessV1PermissionMode;
|
|
970
|
+
kind: 'readonly' | 'edit' | 'bash';
|
|
971
|
+
}): boolean {
|
|
972
|
+
if (input.permissionMode === 'allow-all') return false;
|
|
973
|
+
if (input.permissionMode === 'allow-edits') return input.kind === 'bash';
|
|
974
|
+
return input.kind === 'edit' || input.kind === 'bash';
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function buildBuiltinToolDefinition(input: {
|
|
978
|
+
native: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
|
|
979
|
+
remoteOps: PiRemoteOps;
|
|
980
|
+
requestApproval: (args: {
|
|
981
|
+
toolCallId: string;
|
|
982
|
+
nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
|
|
983
|
+
}) => Promise<{ approved: boolean; reason?: string }>;
|
|
984
|
+
}): ToolDefinition {
|
|
985
|
+
switch (input.native) {
|
|
986
|
+
case 'read':
|
|
987
|
+
return defineTool({
|
|
988
|
+
name: 'read',
|
|
989
|
+
label: 'read',
|
|
990
|
+
description: 'Read file contents.',
|
|
991
|
+
parameters: Type.Object({ file_path: Type.String() }),
|
|
992
|
+
async execute(toolCallId, params) {
|
|
993
|
+
const denied = await maybeDenyPiBuiltinTool({
|
|
994
|
+
toolCallId,
|
|
995
|
+
nativeName: 'read',
|
|
996
|
+
requestApproval: input.requestApproval,
|
|
997
|
+
});
|
|
998
|
+
if (denied) return denied;
|
|
999
|
+
const buf = await input.remoteOps.readBuffer(params.file_path);
|
|
1000
|
+
return asPiToolResult(buf.toString('utf8'));
|
|
1001
|
+
},
|
|
1002
|
+
});
|
|
1003
|
+
case 'write':
|
|
1004
|
+
return defineTool({
|
|
1005
|
+
name: 'write',
|
|
1006
|
+
label: 'write',
|
|
1007
|
+
description: 'Write content to a file.',
|
|
1008
|
+
parameters: Type.Object({
|
|
1009
|
+
file_path: Type.String(),
|
|
1010
|
+
content: Type.String(),
|
|
1011
|
+
}),
|
|
1012
|
+
async execute(toolCallId, params) {
|
|
1013
|
+
const denied = await maybeDenyPiBuiltinTool({
|
|
1014
|
+
toolCallId,
|
|
1015
|
+
nativeName: 'write',
|
|
1016
|
+
requestApproval: input.requestApproval,
|
|
1017
|
+
});
|
|
1018
|
+
if (denied) return denied;
|
|
1019
|
+
await input.remoteOps.writeFile(params.file_path, params.content);
|
|
1020
|
+
return asPiToolResult(`Wrote ${params.file_path}`);
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
case 'edit':
|
|
1024
|
+
return defineTool({
|
|
1025
|
+
name: 'edit',
|
|
1026
|
+
label: 'edit',
|
|
1027
|
+
description: 'Edit a file by exact-string replacement.',
|
|
1028
|
+
parameters: Type.Object({
|
|
1029
|
+
file_path: Type.String(),
|
|
1030
|
+
old_string: Type.String(),
|
|
1031
|
+
new_string: Type.String(),
|
|
1032
|
+
}),
|
|
1033
|
+
async execute(toolCallId, params) {
|
|
1034
|
+
const denied = await maybeDenyPiBuiltinTool({
|
|
1035
|
+
toolCallId,
|
|
1036
|
+
nativeName: 'edit',
|
|
1037
|
+
requestApproval: input.requestApproval,
|
|
1038
|
+
});
|
|
1039
|
+
if (denied) return denied;
|
|
1040
|
+
await input.remoteOps.editFile(
|
|
1041
|
+
params.file_path,
|
|
1042
|
+
params.old_string,
|
|
1043
|
+
params.new_string,
|
|
1044
|
+
);
|
|
1045
|
+
return asPiToolResult(`Edited ${params.file_path}`);
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
case 'bash':
|
|
1049
|
+
return defineTool({
|
|
1050
|
+
name: 'bash',
|
|
1051
|
+
label: 'bash',
|
|
1052
|
+
description: 'Execute a shell command.',
|
|
1053
|
+
parameters: Type.Object({
|
|
1054
|
+
command: Type.String(),
|
|
1055
|
+
timeout: Type.Optional(
|
|
1056
|
+
Type.Number({ description: 'Timeout in seconds.' }),
|
|
1057
|
+
),
|
|
1058
|
+
}),
|
|
1059
|
+
async execute(toolCallId, params, signal) {
|
|
1060
|
+
const denied = await maybeDenyPiBuiltinTool({
|
|
1061
|
+
toolCallId,
|
|
1062
|
+
nativeName: 'bash',
|
|
1063
|
+
requestApproval: input.requestApproval,
|
|
1064
|
+
});
|
|
1065
|
+
if (denied) return denied;
|
|
1066
|
+
const chunks: Buffer[] = [];
|
|
1067
|
+
const result = await input.remoteOps.exec(params.command, '.', {
|
|
1068
|
+
onData(data) {
|
|
1069
|
+
chunks.push(data);
|
|
1070
|
+
},
|
|
1071
|
+
...(signal ? { signal } : {}),
|
|
1072
|
+
...(typeof params.timeout === 'number'
|
|
1073
|
+
? { timeout: params.timeout }
|
|
1074
|
+
: {}),
|
|
1075
|
+
});
|
|
1076
|
+
const out = Buffer.concat(chunks).toString('utf8');
|
|
1077
|
+
const text = `${out}${
|
|
1078
|
+
result.exitCode != null ? `\n\n(exit ${result.exitCode})` : ''
|
|
1079
|
+
}`.trim();
|
|
1080
|
+
return asPiToolResult(text);
|
|
1081
|
+
},
|
|
1082
|
+
});
|
|
1083
|
+
case 'grep':
|
|
1084
|
+
return defineTool({
|
|
1085
|
+
name: 'grep',
|
|
1086
|
+
label: 'grep',
|
|
1087
|
+
description: 'Search file contents with regex.',
|
|
1088
|
+
parameters: Type.Object({
|
|
1089
|
+
pattern: Type.String(),
|
|
1090
|
+
path: Type.Optional(Type.String()),
|
|
1091
|
+
glob: Type.Optional(Type.String()),
|
|
1092
|
+
ignoreCase: Type.Optional(Type.Boolean()),
|
|
1093
|
+
literal: Type.Optional(Type.Boolean()),
|
|
1094
|
+
context: Type.Optional(Type.Number()),
|
|
1095
|
+
limit: Type.Optional(Type.Number()),
|
|
1096
|
+
}),
|
|
1097
|
+
async execute(toolCallId, params) {
|
|
1098
|
+
const denied = await maybeDenyPiBuiltinTool({
|
|
1099
|
+
toolCallId,
|
|
1100
|
+
nativeName: 'grep',
|
|
1101
|
+
requestApproval: input.requestApproval,
|
|
1102
|
+
});
|
|
1103
|
+
if (denied) return denied;
|
|
1104
|
+
const out = await input.remoteOps.grepFiles(params.pattern, params);
|
|
1105
|
+
return asPiToolResult(out);
|
|
1106
|
+
},
|
|
1107
|
+
});
|
|
1108
|
+
case 'find':
|
|
1109
|
+
return defineTool({
|
|
1110
|
+
name: 'find',
|
|
1111
|
+
label: 'find',
|
|
1112
|
+
description: 'Find files matching a glob pattern.',
|
|
1113
|
+
parameters: Type.Object({
|
|
1114
|
+
pattern: Type.String(),
|
|
1115
|
+
path: Type.Optional(Type.String()),
|
|
1116
|
+
limit: Type.Optional(Type.Number()),
|
|
1117
|
+
}),
|
|
1118
|
+
async execute(toolCallId, params) {
|
|
1119
|
+
const denied = await maybeDenyPiBuiltinTool({
|
|
1120
|
+
toolCallId,
|
|
1121
|
+
nativeName: 'find',
|
|
1122
|
+
requestApproval: input.requestApproval,
|
|
1123
|
+
});
|
|
1124
|
+
if (denied) return denied;
|
|
1125
|
+
const matches = await input.remoteOps.findFiles(
|
|
1126
|
+
params.pattern,
|
|
1127
|
+
params.path ?? '.',
|
|
1128
|
+
params.limit ?? 1_000,
|
|
1129
|
+
);
|
|
1130
|
+
return asPiToolResult(matches.join('\n'));
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
case 'ls':
|
|
1134
|
+
return defineTool({
|
|
1135
|
+
name: 'ls',
|
|
1136
|
+
label: 'ls',
|
|
1137
|
+
description: 'List directory entries.',
|
|
1138
|
+
parameters: Type.Object({
|
|
1139
|
+
path: Type.Optional(Type.String()),
|
|
1140
|
+
limit: Type.Optional(Type.Number()),
|
|
1141
|
+
}),
|
|
1142
|
+
async execute(toolCallId, params) {
|
|
1143
|
+
const denied = await maybeDenyPiBuiltinTool({
|
|
1144
|
+
toolCallId,
|
|
1145
|
+
nativeName: 'ls',
|
|
1146
|
+
requestApproval: input.requestApproval,
|
|
1147
|
+
});
|
|
1148
|
+
if (denied) return denied;
|
|
1149
|
+
const entries = await input.remoteOps.listDirectory(
|
|
1150
|
+
params.path ?? '.',
|
|
1151
|
+
params.limit ?? 500,
|
|
1152
|
+
);
|
|
1153
|
+
return asPiToolResult(entries.join('\n'));
|
|
1154
|
+
},
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function buildUserToolDefinition(
|
|
1160
|
+
spec: HarnessV1ToolSpec,
|
|
1161
|
+
pending: Map<string, PendingToolResult>,
|
|
1162
|
+
): ToolDefinition {
|
|
1163
|
+
const schema = spec.inputSchema ?? {
|
|
1164
|
+
type: 'object',
|
|
1165
|
+
properties: {},
|
|
1166
|
+
additionalProperties: true,
|
|
1167
|
+
};
|
|
1168
|
+
return defineTool({
|
|
1169
|
+
name: spec.name,
|
|
1170
|
+
label: spec.name,
|
|
1171
|
+
description: spec.description ?? `User-registered tool ${spec.name}`,
|
|
1172
|
+
parameters: toolSpecToTypeBoxParameters(schema),
|
|
1173
|
+
async execute(toolCallId) {
|
|
1174
|
+
return new Promise<unknown>(resolve => {
|
|
1175
|
+
pending.set(toolCallId, { resolve });
|
|
1176
|
+
}).then(output => asPiToolResult(serializeToolOutput(output)));
|
|
1177
|
+
},
|
|
1178
|
+
});
|
|
1179
|
+
}
|