@vzlentin/harness 0.0.0-stage → 0.1.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/README.md +62 -2
- package/dist/answer.d.ts +29 -0
- package/dist/answer.js +171 -0
- package/dist/chat.d.ts +48 -0
- package/dist/chat.js +208 -0
- package/dist/children.d.ts +211 -0
- package/dist/children.js +390 -0
- package/dist/chrome.d.ts +18 -0
- package/dist/chrome.js +73 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +245 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.js +162 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +115 -0
- package/dist/durable-entry.d.ts +13 -0
- package/dist/durable-entry.js +33 -0
- package/dist/entries.d.ts +9 -0
- package/dist/entries.js +50 -0
- package/dist/herdr.d.ts +36 -0
- package/dist/herdr.js +106 -0
- package/dist/host.d.ts +18 -0
- package/dist/host.js +731 -0
- package/dist/picker.d.ts +22 -0
- package/dist/picker.js +70 -0
- package/dist/projects.d.ts +23 -0
- package/dist/projects.js +93 -0
- package/dist/protocol.d.ts +232 -0
- package/dist/protocol.js +54 -0
- package/dist/tasks.d.ts +31 -0
- package/dist/tasks.js +78 -0
- package/dist/theme.d.ts +3 -0
- package/dist/theme.js +18 -0
- package/dist/tool.d.ts +137 -0
- package/dist/tool.js +209 -0
- package/dist/trust.d.ts +5 -0
- package/dist/trust.js +15 -0
- package/dist/view-state.d.ts +16 -0
- package/dist/view-state.js +39 -0
- package/dist/viewer.d.ts +20 -0
- package/dist/viewer.js +307 -0
- package/package.json +48 -5
package/dist/host.js
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { once } from "node:events";
|
|
11
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { createServer } from "node:net";
|
|
14
|
+
import { join, resolve as resolvePath, sep } from "node:path";
|
|
15
|
+
import { DatabaseSync } from "node:sqlite";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
|
|
18
|
+
import * as ai from "@earendil-works/pi-ai";
|
|
19
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models";
|
|
20
|
+
import { createEventBus, DefaultPackageManager, getAgentDir, ProjectTrustStore, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import * as durable from "@earendil-works/pi-durable";
|
|
22
|
+
import { AgentDoc, createRegistry, defineDoc, Harness, } from "@earendil-works/pi-durable";
|
|
23
|
+
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
24
|
+
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
25
|
+
import { createChildren, resolveModel, } from "./children.js";
|
|
26
|
+
import { ChildContext } from "./context.js";
|
|
27
|
+
import { readEntries } from "./entries.js";
|
|
28
|
+
import { AGENT_HANDLE, onLines, PROTOCOL_VERSION, } from "./protocol.js";
|
|
29
|
+
import { abortTask, startableTasks, startTask, taskInfo, waitTask } from "./tasks.js";
|
|
30
|
+
import { subagentTool } from "./tool.js";
|
|
31
|
+
/** The host exits after this long with no connection, no live task and no request. */
|
|
32
|
+
const IDLE_EXIT_MS = 5 * 60_000;
|
|
33
|
+
const IDLE_CHECK_MS = 60_000;
|
|
34
|
+
const context = BACKGROUND_CONTEXT;
|
|
35
|
+
/**
|
|
36
|
+
* The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
|
|
37
|
+
* children library; a store with version 1 has children that the library does not know.
|
|
38
|
+
*/
|
|
39
|
+
const Children = defineDoc({
|
|
40
|
+
kind: "subagent.children",
|
|
41
|
+
version: 2,
|
|
42
|
+
scope: "session",
|
|
43
|
+
initial: () => ({ children: {}, requests: {} }),
|
|
44
|
+
});
|
|
45
|
+
/** The request ID of the project agent's newest input, which its `error` state comes from. */
|
|
46
|
+
const AgentState = defineDoc({
|
|
47
|
+
kind: "harness.agent",
|
|
48
|
+
version: 1,
|
|
49
|
+
scope: "conversation",
|
|
50
|
+
history: "latest",
|
|
51
|
+
fork: "initial",
|
|
52
|
+
initial: () => ({ lastRequest: null }),
|
|
53
|
+
});
|
|
54
|
+
/** The children library, on this process's pi-durable. */
|
|
55
|
+
const library = createChildren(durable);
|
|
56
|
+
/**
|
|
57
|
+
* Runs the host of `project` in this process: the store in `stateDir`, the children's models from `models`, the
|
|
58
|
+
* project agent in `cwd`, and the socket. It is undefined when another host holds the lock.
|
|
59
|
+
*/
|
|
60
|
+
export async function runHost(options) {
|
|
61
|
+
const { stateDir, project, cwd, models, idleExitMs = IDLE_EXIT_MS } = options;
|
|
62
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
63
|
+
const lock = takeLock(stateDir);
|
|
64
|
+
if (lock === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const socketPath = join(stateDir, "host.sock");
|
|
67
|
+
rmSync(socketPath, { force: true });
|
|
68
|
+
const registry = createRegistry();
|
|
69
|
+
const agentDir = getAgentDir();
|
|
70
|
+
const settings = SettingsManager.create(stateDir, agentDir, {
|
|
71
|
+
projectTrusted: new ProjectTrustStore(agentDir).get(stateDir) === true,
|
|
72
|
+
});
|
|
73
|
+
const warn = (error) => process.stderr.write(`warning: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
74
|
+
// The store is there once the harness is open; entries get `place` before, and use the store in their tasks.
|
|
75
|
+
let harness;
|
|
76
|
+
const place = {
|
|
77
|
+
get store() {
|
|
78
|
+
if (harness === undefined)
|
|
79
|
+
throw new Error("The host is not open yet");
|
|
80
|
+
return harness;
|
|
81
|
+
},
|
|
82
|
+
models,
|
|
83
|
+
get registry() {
|
|
84
|
+
return registry.snapshot();
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const entries = await loadEntries(stateDir, agentDir, settings, place, warn).catch((error) => {
|
|
88
|
+
lock.close();
|
|
89
|
+
throw error;
|
|
90
|
+
});
|
|
91
|
+
const envs = new Map();
|
|
92
|
+
const startable = startableTasks(entries.tasks);
|
|
93
|
+
let base;
|
|
94
|
+
// The project agent is the root conversation, set up at its first use. A failed set-up runs again at the next use.
|
|
95
|
+
let agentSetUp;
|
|
96
|
+
const agent = () => (agentSetUp ??= setUpAgent(base.root, models, settings, project, cwd).catch((error) => {
|
|
97
|
+
agentSetUp = undefined;
|
|
98
|
+
throw error;
|
|
99
|
+
}));
|
|
100
|
+
// A child calls the host in this process, not through the socket, so a long `wait` cannot hold up the requests
|
|
101
|
+
// of a connection. Its caller is its conversation ID, which the depth limit and the owner checks key on.
|
|
102
|
+
const tool = subagentTool(durable, ai, {
|
|
103
|
+
caller: async (api) => String(api.conversationId),
|
|
104
|
+
send: (request, signal) => handleRequest(request, { ...base, signal }),
|
|
105
|
+
});
|
|
106
|
+
const selected = [ChildContext, tool, ...entries.extensions];
|
|
107
|
+
try {
|
|
108
|
+
for (const extension of selected)
|
|
109
|
+
registry.install(extension);
|
|
110
|
+
registry.install(library.extension);
|
|
111
|
+
registry.install(library.answerExtension);
|
|
112
|
+
registry.install(startable);
|
|
113
|
+
harness = await Harness.open(
|
|
114
|
+
// Clients such as the workflows CLI read the store file while the host writes, so wait longer than 5 s for its lock.
|
|
115
|
+
// node:sqlite waits synchronously, so such a wait stops the event loop for as long as it lasts.
|
|
116
|
+
await openNodeSqliteStorage(join(stateDir, "durable.sqlite"), { busyTimeoutMs: 60_000 }), {
|
|
117
|
+
models,
|
|
118
|
+
registry,
|
|
119
|
+
settings: piSettings(settings, selected),
|
|
120
|
+
env: async ({ conversationId, cwd, read }, envContext) => {
|
|
121
|
+
if (cwd === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
const cached = envs.get(conversationId);
|
|
124
|
+
if (cached !== undefined)
|
|
125
|
+
return cached;
|
|
126
|
+
const agent = await read.snapshot(AgentDoc, conversationId, envContext);
|
|
127
|
+
const shellEnv = { PI_SESSION_ID: String(conversationId), HARNESS_PROJECT: project };
|
|
128
|
+
if (agent?.model !== undefined) {
|
|
129
|
+
shellEnv.PI_PROVIDER = agent.model.provider;
|
|
130
|
+
shellEnv.PI_MODEL = agent.model.modelId;
|
|
131
|
+
}
|
|
132
|
+
if (agent?.thinkingLevel !== undefined)
|
|
133
|
+
shellEnv.PI_REASONING_LEVEL = agent.thinkingLevel;
|
|
134
|
+
const env = new NodeExecutionEnv({ cwd, shellEnv });
|
|
135
|
+
envs.set(conversationId, env);
|
|
136
|
+
return env;
|
|
137
|
+
},
|
|
138
|
+
onReport: warn,
|
|
139
|
+
}, context);
|
|
140
|
+
// A store from before the children library would mix children that it does not know with those it does.
|
|
141
|
+
// pi-durable refuses a stored version below the definition's with this message; any other error is not an old store.
|
|
142
|
+
try {
|
|
143
|
+
await harness.snapshot(Children, context);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await harness.close(context);
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
if (!message.includes("(subagent.children) requires migration from version"))
|
|
149
|
+
throw error;
|
|
150
|
+
throw new Error(`${join(stateDir, "durable.sqlite")} was written by an older pi-subagent (${message}). Stop the host, remove the file and run the command again.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
await entries.close();
|
|
155
|
+
lock.close();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
harness.resume();
|
|
159
|
+
// The host's children outlive every request: each has an anchor task in the root conversation. The anchors are
|
|
160
|
+
// background tasks, so an abort of the root's turn, which is the project agent's, does not reach them.
|
|
161
|
+
const root = await harness.root(context);
|
|
162
|
+
base = { harness, place, owner: { kind: "detached", root }, root, project, agent, startable };
|
|
163
|
+
const connections = new Set();
|
|
164
|
+
const aborts = new Set();
|
|
165
|
+
let lastActivity = Date.now();
|
|
166
|
+
const server = createServer((socket) => {
|
|
167
|
+
connections.add(socket);
|
|
168
|
+
const abort = new AbortController();
|
|
169
|
+
aborts.add(abort);
|
|
170
|
+
let queue = Promise.resolve();
|
|
171
|
+
onLines(socket, (line) => {
|
|
172
|
+
queue = queue.then(async () => {
|
|
173
|
+
let reply;
|
|
174
|
+
let start;
|
|
175
|
+
const connection = { socket, afterReply: (afterReply) => (start = afterReply) };
|
|
176
|
+
try {
|
|
177
|
+
const request = JSON.parse(line);
|
|
178
|
+
if (request.type !== "hello" && request.type !== "list" && request.type !== "status") {
|
|
179
|
+
lastActivity = Date.now();
|
|
180
|
+
}
|
|
181
|
+
reply = {
|
|
182
|
+
ok: true,
|
|
183
|
+
value: await handleRequest(request, { ...base, signal: abort.signal, connection }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
reply = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
188
|
+
}
|
|
189
|
+
if (socket.writable)
|
|
190
|
+
socket.write(`${JSON.stringify(reply)}\n`);
|
|
191
|
+
start?.();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
socket.on("error", () => socket.destroy());
|
|
195
|
+
socket.on("close", () => {
|
|
196
|
+
connections.delete(socket);
|
|
197
|
+
aborts.delete(abort);
|
|
198
|
+
abort.abort();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
202
|
+
server.once("error", rejectListen);
|
|
203
|
+
server.listen(socketPath, () => {
|
|
204
|
+
server.off("error", rejectListen);
|
|
205
|
+
resolveListen();
|
|
206
|
+
});
|
|
207
|
+
}).catch(async (error) => {
|
|
208
|
+
await entries.close();
|
|
209
|
+
await harness.close(context);
|
|
210
|
+
lock.close();
|
|
211
|
+
throw error;
|
|
212
|
+
});
|
|
213
|
+
let closing;
|
|
214
|
+
const close = () => {
|
|
215
|
+
closing ??= (async () => {
|
|
216
|
+
clearInterval(timer);
|
|
217
|
+
server.close();
|
|
218
|
+
for (const socket of connections)
|
|
219
|
+
socket.destroy();
|
|
220
|
+
for (const abort of aborts)
|
|
221
|
+
abort.abort();
|
|
222
|
+
try {
|
|
223
|
+
// Entries close first: an entry may still need the open harness.
|
|
224
|
+
await entries.close();
|
|
225
|
+
await harness.close(context);
|
|
226
|
+
for (const env of envs.values())
|
|
227
|
+
await env.cleanup(context);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
rmSync(socketPath, { force: true });
|
|
231
|
+
lock.close();
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
return closing;
|
|
235
|
+
};
|
|
236
|
+
const timer = setInterval(() => {
|
|
237
|
+
if (connections.size > 0 || Date.now() - lastActivity < idleExitMs)
|
|
238
|
+
return;
|
|
239
|
+
harness
|
|
240
|
+
.inspect(context)
|
|
241
|
+
.then(({ tasks }) => (liveTasks(tasks) === 0 && connections.size === 0 ? close() : undefined))
|
|
242
|
+
.catch(warn);
|
|
243
|
+
}, Math.min(IDLE_CHECK_MS, idleExitMs));
|
|
244
|
+
timer.unref();
|
|
245
|
+
// A store failure closes the harness; the host then closes too.
|
|
246
|
+
const closed = harness.closed.then(() => close());
|
|
247
|
+
return { closed, close };
|
|
248
|
+
}
|
|
249
|
+
async function handleRequest(request, handler) {
|
|
250
|
+
switch (request.type) {
|
|
251
|
+
case "hello":
|
|
252
|
+
return { version: PROTOCOL_VERSION, pid: process.pid };
|
|
253
|
+
case "spawn":
|
|
254
|
+
return spawnChild(request, handler);
|
|
255
|
+
case "send":
|
|
256
|
+
return sendTo(request, await childTarget(handler, request.handle, await ownRecord(handler.harness, request.handle, request.caller)));
|
|
257
|
+
case "input":
|
|
258
|
+
return sendTo(request, await target(handler, request.handle));
|
|
259
|
+
case "wait":
|
|
260
|
+
return waitChild(request, handler);
|
|
261
|
+
case "status":
|
|
262
|
+
return childInfo(handler, request.handle);
|
|
263
|
+
case "abort":
|
|
264
|
+
return abortChild(request, handler);
|
|
265
|
+
case "configure":
|
|
266
|
+
return configureChild(request, handler);
|
|
267
|
+
case "models":
|
|
268
|
+
return modelInfos(handler.place.models);
|
|
269
|
+
case "watch":
|
|
270
|
+
if (handler.connection === undefined)
|
|
271
|
+
throw new Error("A watch needs a connection");
|
|
272
|
+
return serveWatch(handler, request.handle, handler.connection);
|
|
273
|
+
case "start":
|
|
274
|
+
// A child that a task opens without a model takes the project agent's, so the agent is set up first. Without
|
|
275
|
+
// a model for the agent, the task still starts: its children may name their own.
|
|
276
|
+
await handler.agent().catch(() => undefined);
|
|
277
|
+
return startTask(handler, request);
|
|
278
|
+
case "task":
|
|
279
|
+
return taskInfo(handler.harness, request.key);
|
|
280
|
+
case "wait-task":
|
|
281
|
+
return waitTask(handler, request.key);
|
|
282
|
+
case "abort-task":
|
|
283
|
+
return abortTask(handler.harness, request.key);
|
|
284
|
+
case "list": {
|
|
285
|
+
const state = await handler.harness.snapshot(Children, context);
|
|
286
|
+
const own = Object.entries(state?.children ?? {})
|
|
287
|
+
.filter(([, record]) => record.caller === request.caller)
|
|
288
|
+
.sort(([, a], [, b]) => a.createdAt.localeCompare(b.createdAt));
|
|
289
|
+
// A child that the library closed outside `stop`, such as a subagent of a workflow session when its run ends,
|
|
290
|
+
// keeps its record until a `stop`, and is not listed.
|
|
291
|
+
const infos = await Promise.all(own.map(async ([handle, record]) => (await (await childOf(handler, record)).state(context)) === "closed"
|
|
292
|
+
? undefined
|
|
293
|
+
: childInfo(handler, handle, record)));
|
|
294
|
+
return infos.filter((info) => info !== undefined);
|
|
295
|
+
}
|
|
296
|
+
case "rename": {
|
|
297
|
+
await ownRecord(handler.harness, request.handle, request.caller);
|
|
298
|
+
await handler.harness.commit(async (tx) => {
|
|
299
|
+
const record = (await tx.doc(Children)).children[request.handle];
|
|
300
|
+
if (record !== undefined)
|
|
301
|
+
record.name = request.name;
|
|
302
|
+
}, context);
|
|
303
|
+
return childInfo(handler, request.handle);
|
|
304
|
+
}
|
|
305
|
+
case "stop": {
|
|
306
|
+
const record = await ownRecord(handler.harness, request.handle, request.caller);
|
|
307
|
+
// Stopping a child stops the children it spawned, and removes them all.
|
|
308
|
+
const closed = new Set((await (await childOf(handler, record)).close(context)).map(String));
|
|
309
|
+
await handler.harness.commit(async (tx) => {
|
|
310
|
+
const state = await tx.doc(Children);
|
|
311
|
+
const removed = new Set(Object.keys(state.children).filter((handle) => {
|
|
312
|
+
const child = state.children[handle];
|
|
313
|
+
return child !== undefined && closed.has(String(child.conversationId));
|
|
314
|
+
}));
|
|
315
|
+
for (const handle of removed)
|
|
316
|
+
delete state.children[handle];
|
|
317
|
+
for (const [requestId, handle] of Object.entries(state.requests)) {
|
|
318
|
+
if (removed.has(handle))
|
|
319
|
+
delete state.requests[requestId];
|
|
320
|
+
}
|
|
321
|
+
}, context);
|
|
322
|
+
return { handle: request.handle, ...(record.name === undefined ? {} : { name: record.name }) };
|
|
323
|
+
}
|
|
324
|
+
default:
|
|
325
|
+
throw new Error(`Unknown request: ${request.type}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function spawnChild(request, handler) {
|
|
329
|
+
if (!request.cwd)
|
|
330
|
+
throw new Error("spawn requires a working directory");
|
|
331
|
+
const { harness, place } = handler;
|
|
332
|
+
const state = await harness.snapshot(Children, context);
|
|
333
|
+
let handle = state?.requests[request.requestId];
|
|
334
|
+
if (handle === undefined) {
|
|
335
|
+
// A caller that is a child of this host or the project agent is the parent; any other caller is a root.
|
|
336
|
+
const parent = (await library.lineage(harness, request.caller, context)) !== undefined ||
|
|
337
|
+
request.caller === String(handler.root.id)
|
|
338
|
+
? request.caller
|
|
339
|
+
: undefined;
|
|
340
|
+
const spec = {
|
|
341
|
+
...(parent === undefined ? { root: request.caller } : { parent }),
|
|
342
|
+
provider: request.provider,
|
|
343
|
+
model: request.model,
|
|
344
|
+
...(request.thinking === undefined ? {} : { thinking: request.thinking }),
|
|
345
|
+
fallbackThinking: request.callerThinking,
|
|
346
|
+
cwd: request.cwd,
|
|
347
|
+
...(request.tools === undefined ? {} : { tools: request.tools }),
|
|
348
|
+
};
|
|
349
|
+
// The record is written in the commit that creates the child.
|
|
350
|
+
await library.open(place, handler.owner, `sub:${request.requestId}`, spec, context, async (tx, id) => {
|
|
351
|
+
const current = await tx.doc(Children);
|
|
352
|
+
let fresh = randomBytes(3).toString("hex");
|
|
353
|
+
while (Object.hasOwn(current.children, fresh))
|
|
354
|
+
fresh = randomBytes(3).toString("hex");
|
|
355
|
+
current.children[fresh] = {
|
|
356
|
+
...(request.name === undefined ? {} : { name: request.name }),
|
|
357
|
+
caller: request.caller,
|
|
358
|
+
createdAt: new Date().toISOString(),
|
|
359
|
+
conversationId: id,
|
|
360
|
+
};
|
|
361
|
+
current.requests[request.requestId] = fresh;
|
|
362
|
+
});
|
|
363
|
+
handle = (await harness.snapshot(Children, context))?.requests[request.requestId];
|
|
364
|
+
if (handle === undefined)
|
|
365
|
+
throw new Error(`Missing subagent of ${request.requestId}`);
|
|
366
|
+
}
|
|
367
|
+
const record = await childRecord(harness, handle);
|
|
368
|
+
// The request ID makes a repeated spawn find the first submission, also after a crash before it.
|
|
369
|
+
await (await childOf(handler, record)).send({ type: "input", content: request.prompt, requestId: `spawn:${request.requestId}` }, context);
|
|
370
|
+
return childInfo(handler, handle, record);
|
|
371
|
+
}
|
|
372
|
+
/** Sends the input of `request` to `found`. */
|
|
373
|
+
async function sendTo(request, found) {
|
|
374
|
+
const busy = (await found.info()).state === "busy";
|
|
375
|
+
await found.send({
|
|
376
|
+
type: "input",
|
|
377
|
+
content: request.message,
|
|
378
|
+
whenBusy: request.followUp ? "followUp" : "steer",
|
|
379
|
+
requestId: request.requestId,
|
|
380
|
+
});
|
|
381
|
+
return { verb: sendVerb(busy, request.followUp), child: await found.info() };
|
|
382
|
+
}
|
|
383
|
+
function sendVerb(busy, followUp) {
|
|
384
|
+
return !busy ? "Prompted" : followUp ? "Queued follow-up for" : "Steered";
|
|
385
|
+
}
|
|
386
|
+
async function waitChild(request, handler) {
|
|
387
|
+
const child = await childOf(handler, await childRecord(handler.harness, request.handle));
|
|
388
|
+
const timeout = AbortSignal.timeout(request.timeout * 1000);
|
|
389
|
+
let answer;
|
|
390
|
+
try {
|
|
391
|
+
answer = await child.settled(withAbortSignal(AbortSignal.any([timeout, handler.signal]), context));
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (timeout.aborted)
|
|
395
|
+
throw new Error(`Timed out after ${request.timeout}s waiting for ${request.handle}`);
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
// A cut answer still gives its text.
|
|
399
|
+
if (answer.status === "ok" || answer.reason !== "unanswered")
|
|
400
|
+
return { answer: answer.text };
|
|
401
|
+
if (answer.cause === "queued")
|
|
402
|
+
throw new Error(`${request.handle} stopped with queued messages after a failed turn`);
|
|
403
|
+
throw new Error(failureText(request.handle, answer.cause, answer.detail));
|
|
404
|
+
}
|
|
405
|
+
async function abortChild(request, handler) {
|
|
406
|
+
const found = await target(handler, request.handle);
|
|
407
|
+
// Without `background`, an abort of the project agent does not reach the anchors of the children, which keep running.
|
|
408
|
+
await found.conversation.abort(context);
|
|
409
|
+
return found.info();
|
|
410
|
+
}
|
|
411
|
+
async function configureChild(request, handler) {
|
|
412
|
+
const found = await target(handler, request.handle);
|
|
413
|
+
const agent = await found.conversation.agent(context);
|
|
414
|
+
await found.conversation.configure(resolveModel(handler.place.models, agent, request), context);
|
|
415
|
+
return found.info();
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The conversation of `handle`, a child or the project agent, with its info, its input, and the record of a child.
|
|
419
|
+
*/
|
|
420
|
+
async function target(handler, handle) {
|
|
421
|
+
if (handle !== AGENT_HANDLE)
|
|
422
|
+
return childTarget(handler, handle, await childRecord(handler.harness, handle));
|
|
423
|
+
const root = await handler.agent();
|
|
424
|
+
const { harness, place, project } = handler;
|
|
425
|
+
const turn = library.turn(place, root.id, root, async (readContext) => (await harness.snapshot(AgentState, root.id, readContext))?.lastRequest, async (tx, requestId) => {
|
|
426
|
+
(await tx.doc(AgentState, root.id)).lastRequest = requestId;
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
conversation: root,
|
|
430
|
+
info: async () => ({
|
|
431
|
+
...(await agentFields(root)),
|
|
432
|
+
handle,
|
|
433
|
+
name: `${project} agent`,
|
|
434
|
+
state: await turn.state(context),
|
|
435
|
+
}),
|
|
436
|
+
send: (input) => turn.send(input, context),
|
|
437
|
+
record: undefined,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/** The target of the child `handle`, whose record is `record`. */
|
|
441
|
+
async function childTarget(handler, handle, record) {
|
|
442
|
+
const child = await childOf(handler, record);
|
|
443
|
+
return {
|
|
444
|
+
conversation: child.conversation,
|
|
445
|
+
info: () => childInfo(handler, handle, record),
|
|
446
|
+
send: (input) => child.send(input, context),
|
|
447
|
+
record,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
async function modelInfos(models) {
|
|
451
|
+
return (await models.getAvailable()).map((model) => ({
|
|
452
|
+
provider: model.provider,
|
|
453
|
+
id: model.id,
|
|
454
|
+
name: model.name,
|
|
455
|
+
contextWindow: model.contextWindow,
|
|
456
|
+
thinkingLevels: getSupportedThinkingLevels(model),
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
async function childRecord(harness, handle) {
|
|
460
|
+
const state = await harness.snapshot(Children, context);
|
|
461
|
+
const record = recordOf(state, handle);
|
|
462
|
+
if (record === undefined)
|
|
463
|
+
throw new Error(`Unknown subagent: ${handle}`);
|
|
464
|
+
return record;
|
|
465
|
+
}
|
|
466
|
+
/** The record of `handle`, which must belong to `caller`. */
|
|
467
|
+
async function ownRecord(harness, handle, caller) {
|
|
468
|
+
const record = await childRecord(harness, handle);
|
|
469
|
+
if (record.caller !== caller)
|
|
470
|
+
throw new Error(`Subagent ${handle} belongs to another session`);
|
|
471
|
+
return record;
|
|
472
|
+
}
|
|
473
|
+
async function childOf(handler, record) {
|
|
474
|
+
const child = await library.find(handler.place, handler.owner, { id: String(record.conversationId) }, context);
|
|
475
|
+
if (child === undefined)
|
|
476
|
+
throw new Error(`Missing conversation ${record.conversationId}`);
|
|
477
|
+
return child;
|
|
478
|
+
}
|
|
479
|
+
function failureText(handle, reason, detail) {
|
|
480
|
+
if (reason === "aborted")
|
|
481
|
+
return `${handle} was aborted`;
|
|
482
|
+
const text = typeof detail === "string"
|
|
483
|
+
? detail
|
|
484
|
+
: typeof detail?.message === "string"
|
|
485
|
+
? detail.message
|
|
486
|
+
: undefined;
|
|
487
|
+
return text === undefined ? `${handle} failed: ${reason}` : `${handle} failed: ${text}`;
|
|
488
|
+
}
|
|
489
|
+
/** The info of `handle`. A caller that already has the record gives it as `record`. */
|
|
490
|
+
async function childInfo(handler, handle, record) {
|
|
491
|
+
record ??= await childRecord(handler.harness, handle);
|
|
492
|
+
const child = await childOf(handler, record);
|
|
493
|
+
const childState = await child.state(context);
|
|
494
|
+
// A closed child is idle: the watch can still send it before its record goes and the `stopped` line follows.
|
|
495
|
+
const state = childState === "closed" ? "idle" : childState;
|
|
496
|
+
return {
|
|
497
|
+
handle,
|
|
498
|
+
...(record.name === undefined ? {} : { name: record.name }),
|
|
499
|
+
state,
|
|
500
|
+
...(await agentFields(child.conversation)),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** The model, thinking level and directory of the agent of `conversation`, for its info. */
|
|
504
|
+
async function agentFields(conversation) {
|
|
505
|
+
const agent = await conversation.agent(context);
|
|
506
|
+
return {
|
|
507
|
+
provider: agent.model?.provider ?? "",
|
|
508
|
+
model: agent.model?.modelId ?? "",
|
|
509
|
+
thinking: agent.thinkingLevel,
|
|
510
|
+
// `spawnChild` refuses a spawn without `cwd`; the fallback only satisfies pi-durable's optional type.
|
|
511
|
+
cwd: agent.cwd ?? "",
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The root conversation as the agent of `project`. The first time, it gets Pi's default model when it has a login, else
|
|
516
|
+
* the first model with a login, Pi's default thinking level, and the directory `cwd`. Without a model with a login, it
|
|
517
|
+
* throws.
|
|
518
|
+
*/
|
|
519
|
+
async function setUpAgent(root, models, settings, project, cwd) {
|
|
520
|
+
if ((await root.agent(context)).model !== undefined)
|
|
521
|
+
return root;
|
|
522
|
+
// A default set after the host started applies.
|
|
523
|
+
await settings.reload();
|
|
524
|
+
const available = await models.getAvailable();
|
|
525
|
+
const provider = settings.getDefaultProvider();
|
|
526
|
+
const modelId = settings.getDefaultModel();
|
|
527
|
+
const model = available.find((found) => found.provider === provider && found.id === modelId) ?? available[0];
|
|
528
|
+
if (model === undefined) {
|
|
529
|
+
throw new Error(`No model for the ${project} agent: log in to a provider and set a default model in Pi (defaultProvider and defaultModel)`);
|
|
530
|
+
}
|
|
531
|
+
const resolved = resolveModel(models, undefined, {
|
|
532
|
+
provider: model.provider,
|
|
533
|
+
model: model.id,
|
|
534
|
+
fallbackThinking: settings.getDefaultThinkingLevel() ?? "medium",
|
|
535
|
+
});
|
|
536
|
+
await root.configure({ ...resolved, cwd }, context);
|
|
537
|
+
return root;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Opens a watch of `handle` on `connection` and returns the reply, with the view and the child. After the reply, each
|
|
541
|
+
* commit to the child's conversation and each rename gives one `{ event: WatchLine }` line. It waits for a slow socket
|
|
542
|
+
* to drain, so pi-durable replaces the frames it holds back with one frame of the whole view. The watches stop when the
|
|
543
|
+
* socket closes, when setup fails, or when the connection ends; a wait for `drain` then stays on the closed socket and
|
|
544
|
+
* goes with it. When the child stops, the viewer gets a `stopped` event and the connection ends; when the conversation
|
|
545
|
+
* watch ends for another reason, the connection ends.
|
|
546
|
+
*/
|
|
547
|
+
async function serveWatch(handler, handle, connection) {
|
|
548
|
+
const { harness } = handler;
|
|
549
|
+
const { socket } = connection;
|
|
550
|
+
const write = (event) => socket.write(`${JSON.stringify({ event })}\n`);
|
|
551
|
+
// pi-durable stops both watches when `signal` aborts: on socket close, or when `watching` aborts.
|
|
552
|
+
const watching = new AbortController();
|
|
553
|
+
const watchContext = withAbortSignal(AbortSignal.any([handler.signal, watching.signal]), context);
|
|
554
|
+
const end = () => {
|
|
555
|
+
watching.abort();
|
|
556
|
+
if (socket.writable)
|
|
557
|
+
socket.end();
|
|
558
|
+
};
|
|
559
|
+
let found;
|
|
560
|
+
let children;
|
|
561
|
+
let conversation;
|
|
562
|
+
let last;
|
|
563
|
+
try {
|
|
564
|
+
found = await target(handler, handle);
|
|
565
|
+
// The project agent has no record, and no `Children` watch: it is never renamed or stopped.
|
|
566
|
+
if (found.record !== undefined) {
|
|
567
|
+
// The `Children` watch opens before the info, so it gets each later rename and stop of the child.
|
|
568
|
+
children = await harness.watchDoc(Children, watchContext);
|
|
569
|
+
const record = recordOf(children?.value, handle);
|
|
570
|
+
if (children === undefined || record === undefined)
|
|
571
|
+
throw new Error(`Unknown subagent: ${handle}`);
|
|
572
|
+
found = { ...found, record, info: () => childInfo(handler, handle, record) };
|
|
573
|
+
}
|
|
574
|
+
last = await found.info();
|
|
575
|
+
conversation = await found.conversation.watch(watchContext);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
watching.abort();
|
|
579
|
+
throw error;
|
|
580
|
+
}
|
|
581
|
+
const childrenWatch = children;
|
|
582
|
+
// pi-durable delivers the frames held before `start` in a microtask, so the watches start after the reply is written.
|
|
583
|
+
connection.afterReply(() => {
|
|
584
|
+
// Only microtasks run between the last `await` above and this call, so a socket close cannot abort the signal
|
|
585
|
+
// before the watches start.
|
|
586
|
+
void conversation.closed.then(end);
|
|
587
|
+
// pi-durable runs the callbacks of one watch in order, but not those of two watches. Both watches write through
|
|
588
|
+
// this one chain, so a line never has older info than the line before it.
|
|
589
|
+
let queue = Promise.resolve();
|
|
590
|
+
const send = (ops) => {
|
|
591
|
+
queue = queue.then(async () => {
|
|
592
|
+
// Some store reads per commit; cache the agent fields if streaming gets slow. A store failure drops this
|
|
593
|
+
// line. A stopped child still gives `idle` here, and its `stopped` line follows.
|
|
594
|
+
const child = await found.info().catch(() => undefined);
|
|
595
|
+
if (child === undefined || !socket.writable)
|
|
596
|
+
return;
|
|
597
|
+
if (!write({ ops, child }))
|
|
598
|
+
await once(socket, "drain").catch(() => { });
|
|
599
|
+
});
|
|
600
|
+
return queue;
|
|
601
|
+
};
|
|
602
|
+
childrenWatch?.start(async (state) => {
|
|
603
|
+
if (!socket.writable)
|
|
604
|
+
return;
|
|
605
|
+
const next = recordOf(state, handle);
|
|
606
|
+
if (next === undefined) {
|
|
607
|
+
write({ stopped: true });
|
|
608
|
+
end();
|
|
609
|
+
}
|
|
610
|
+
else if (next.name !== found.record?.name) {
|
|
611
|
+
found = { ...found, record: next, info: () => childInfo(handler, handle, next) };
|
|
612
|
+
await send([]);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
conversation.start((_view, ops) => send(ops));
|
|
616
|
+
});
|
|
617
|
+
return { view: conversation.value, child: last };
|
|
618
|
+
}
|
|
619
|
+
/** The record of `handle` in the `Children` value `state`. */
|
|
620
|
+
function recordOf(state, handle) {
|
|
621
|
+
return state != null && Object.hasOwn(state.children, handle) ? state.children[handle] : undefined;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The lock of the host, a write transaction on `lock.sqlite`, held until `close()`. The system drops it when the
|
|
625
|
+
* process ends. It is undefined when another process holds it. `IMMEDIATE`, not `EXCLUSIVE`: two hosts that start at
|
|
626
|
+
* the same time can both fail `EXCLUSIVE`, each holding a lock the other one needs, and then no host runs.
|
|
627
|
+
*/
|
|
628
|
+
function takeLock(directory) {
|
|
629
|
+
const database = new DatabaseSync(join(directory, "lock.sqlite"), { timeout: 0 });
|
|
630
|
+
try {
|
|
631
|
+
database.exec("BEGIN IMMEDIATE");
|
|
632
|
+
return database;
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
database.close();
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** Pi's run settings from `settings`, read at every use, with `extensions` as the default selection. */
|
|
640
|
+
function piSettings(settings, extensions) {
|
|
641
|
+
return {
|
|
642
|
+
extensions,
|
|
643
|
+
get stream() {
|
|
644
|
+
const provider = settings.getProviderRetrySettings();
|
|
645
|
+
const idle = settings.getHttpIdleTimeoutMs();
|
|
646
|
+
return {
|
|
647
|
+
timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
|
|
648
|
+
maxRetryDelayMs: provider.maxRetryDelayMs,
|
|
649
|
+
...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
get retry() {
|
|
653
|
+
return settings.getRetrySettings();
|
|
654
|
+
},
|
|
655
|
+
get compaction() {
|
|
656
|
+
return settings.getCompactionSettings();
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The tasks that keep the host running. A blocked task, such as one of an entry that is no longer installed, never runs
|
|
662
|
+
* again in this host, and neither does a task that waits only on such tasks.
|
|
663
|
+
*/
|
|
664
|
+
function liveTasks(tasks) {
|
|
665
|
+
const dead = new Set(tasks.filter((task) => task.state.kind === "blocked").map((task) => task.record.id));
|
|
666
|
+
for (let grown = true; grown;) {
|
|
667
|
+
grown = false;
|
|
668
|
+
for (const task of tasks) {
|
|
669
|
+
const { state } = task;
|
|
670
|
+
if (dead.has(task.record.id) || state.kind !== "waiting" || !state.on.every((id) => dead.has(id)))
|
|
671
|
+
continue;
|
|
672
|
+
dead.add(task.record.id);
|
|
673
|
+
grown = true;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return tasks.length - dead.size;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The `piDurable` entries of the installed packages in Pi's `settingsManager` for `cwd`, then the entries of
|
|
680
|
+
* `entries.json` (see `readEntries`), in one shared event bus, as one entry whose `close()` closes each of them and
|
|
681
|
+
* passes its errors to `warn`. When an entry fails to load, the entries loaded before it close. Missing packages are
|
|
682
|
+
* skipped, not installed, and so is an entry module that does not exist, such as one of an uninstalled package.
|
|
683
|
+
*/
|
|
684
|
+
async function loadEntries(cwd, agentDir, settingsManager, place, warn) {
|
|
685
|
+
const modules = new Set();
|
|
686
|
+
const roots = new DefaultPackageManager({ cwd, agentDir, settingsManager })
|
|
687
|
+
.listConfiguredPackages()
|
|
688
|
+
.flatMap((found) => found.installedPath ?? []);
|
|
689
|
+
for (const root of new Set(roots)) {
|
|
690
|
+
const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8").catch(() => "{}"));
|
|
691
|
+
for (const path of manifest.piDurable?.extensions ?? [])
|
|
692
|
+
modules.add(resolvePath(root, path));
|
|
693
|
+
}
|
|
694
|
+
for (const module of Object.values(readEntries()))
|
|
695
|
+
if (existsSync(module))
|
|
696
|
+
modules.add(module);
|
|
697
|
+
const events = createEventBus();
|
|
698
|
+
const entries = [];
|
|
699
|
+
const close = async () => {
|
|
700
|
+
for (const result of await Promise.allSettled(entries.map((entry) => entry.close()))) {
|
|
701
|
+
if (result.status === "rejected")
|
|
702
|
+
warn(result.reason);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
try {
|
|
706
|
+
for (const module of modules) {
|
|
707
|
+
const entry = await importEntry(module);
|
|
708
|
+
entries.push(await entry.default({ durable, ai, events, children: library, place }));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
await close();
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
extensions: entries.flatMap((entry) => entry.extensions),
|
|
717
|
+
tasks: entries.flatMap((entry) => entry.tasks ?? []),
|
|
718
|
+
close,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Imports the entry `module`. Node does not strip types under `node_modules`, where Pi installs npm packages, so jiti
|
|
723
|
+
* loads a TypeScript entry there.
|
|
724
|
+
*/
|
|
725
|
+
async function importEntry(module) {
|
|
726
|
+
if (module.endsWith(".ts") && module.split(sep).includes("node_modules")) {
|
|
727
|
+
const { createJiti } = await import("jiti");
|
|
728
|
+
return createJiti(import.meta.url).import(module);
|
|
729
|
+
}
|
|
730
|
+
return import(__rewriteRelativeImportExtension(pathToFileURL(module).href));
|
|
731
|
+
}
|