@tshu-w/pi-agents 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/LICENSE +661 -0
- package/README.md +81 -2
- package/bin/daemon.mjs +204 -0
- package/bin/host.mjs +170 -0
- package/bin/pd.mjs +139 -0
- package/docs/PROGRAM.md +310 -0
- package/docs/SPEC.md +427 -0
- package/docs/WORKBENCH.md +165 -0
- package/package.json +62 -4
- package/src/agents/agents.ts +882 -0
- package/src/agents/registry.ts +257 -0
- package/src/agents/scheduler.ts +204 -0
- package/src/agents/search.ts +131 -0
- package/src/agents/tool.ts +205 -0
- package/src/daemon/client.mjs +84 -0
- package/src/daemon/router.mjs +103 -0
- package/src/daemon/sessions.mjs +129 -0
- package/src/daemon/version.mjs +19 -0
- package/src/host/client.mjs +139 -0
- package/src/host/extension.ts +74 -0
- package/src/host/frames.mjs +34 -0
- package/src/host/keys.mjs +19 -0
- package/src/index.ts +208 -0
- package/src/output.ts +56 -0
- package/src/params.ts +4 -0
- package/src/pi.mjs +16 -0
- package/src/programs/codemode.ts +74 -0
- package/src/programs/execute.ts +131 -0
- package/src/programs/host-patches.ts +128 -0
- package/src/programs/loadout.ts +99 -0
- package/src/programs/programs.ts +284 -0
- package/src/programs/sandbox.ts +90 -0
- package/src/programs/scope.ts +94 -0
- package/src/programs/tool.ts +198 -0
- package/src/render-call.ts +90 -0
- package/src/roots/discovery.mjs +113 -0
- package/src/roots/guard.mjs +227 -0
- package/src/roots/lock-extension.mjs +167 -0
- package/src/roots/locks.mjs +167 -0
- package/src/roots/paths.mjs +66 -0
- package/src/roots/report.ts +156 -0
- package/src/roots/runtime.ts +174 -0
- package/src/roots/snapshot.mjs +46 -0
- package/src/roots/socket-recovery.mjs +21 -0
- package/src/roots/transport.mjs +341 -0
- package/src/ui/panel.ts +96 -0
- package/src/ui/viewer.ts +445 -0
- package/src/workbench.mjs +587 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAgentSession,
|
|
3
|
+
DefaultResourceLoader,
|
|
4
|
+
hasTrustRequiringProjectResources,
|
|
5
|
+
ProjectTrustStore,
|
|
6
|
+
SessionManager,
|
|
7
|
+
SettingsManager,
|
|
8
|
+
type AgentSession,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
type ExtensionError,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import {
|
|
19
|
+
customMessage,
|
|
20
|
+
deliver,
|
|
21
|
+
label,
|
|
22
|
+
messageText,
|
|
23
|
+
MESSAGE_TYPE,
|
|
24
|
+
nodes,
|
|
25
|
+
resolveTarget,
|
|
26
|
+
scheduler,
|
|
27
|
+
shortId,
|
|
28
|
+
TREE_ENTRY,
|
|
29
|
+
trackTreeWork,
|
|
30
|
+
treeChanged,
|
|
31
|
+
visibleIds,
|
|
32
|
+
type AgentState,
|
|
33
|
+
type Delivery,
|
|
34
|
+
type Entry,
|
|
35
|
+
type TreeMetadata,
|
|
36
|
+
} from "./registry.ts";
|
|
37
|
+
|
|
38
|
+
const AGENT_ENTRY = "pi-agents-agent";
|
|
39
|
+
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
|
40
|
+
const EXTENSION_PATH = fileURLToPath(new URL("../index.ts", import.meta.url));
|
|
41
|
+
export const SUBMIT_RESULT_TOOL_NAME = "submit_result";
|
|
42
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
43
|
+
|
|
44
|
+
export type Outcome = "completed" | "failed" | "aborted";
|
|
45
|
+
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
46
|
+
type Model = NonNullable<ExtensionContext["model"]>;
|
|
47
|
+
type QueuedMessage = Parameters<AgentSession["agent"]["steer"]>[0];
|
|
48
|
+
type CreateOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
|
|
49
|
+
|
|
50
|
+
export interface Limits {
|
|
51
|
+
maxConcurrent: number;
|
|
52
|
+
maxOutstanding: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface InputRecord {
|
|
56
|
+
id: string;
|
|
57
|
+
state: "queued" | "running" | Outcome;
|
|
58
|
+
result?: string;
|
|
59
|
+
/** The value submitted with `submit_result`, for an input with a schema. */
|
|
60
|
+
value?: unknown;
|
|
61
|
+
read: boolean;
|
|
62
|
+
notified: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AgentRecord {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
sessionFile: string;
|
|
69
|
+
cwd: string;
|
|
70
|
+
createdAt: string;
|
|
71
|
+
inputs: InputRecord[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
function defaultSessionDirectory(cwd: string, agentDir: string): string {
|
|
76
|
+
const safePath = `--${path.resolve(cwd).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
77
|
+
return path.join(path.resolve(agentDir), "sessions", safePath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function childSessionDirectory(cwd: string, currentCwd: string, currentSessionDir: string, agentDir: string): string {
|
|
81
|
+
const targetDefault = defaultSessionDirectory(cwd, agentDir);
|
|
82
|
+
if (!currentSessionDir) return path.join(targetDefault, "subagents");
|
|
83
|
+
const current = path.resolve(currentSessionDir);
|
|
84
|
+
const currentDefault = defaultSessionDirectory(currentCwd, agentDir);
|
|
85
|
+
if (current === currentDefault ||
|
|
86
|
+
(path.basename(current) === "subagents" && path.dirname(current) === currentDefault)) {
|
|
87
|
+
return path.join(targetDefault, "subagents");
|
|
88
|
+
}
|
|
89
|
+
return path.basename(current) === "subagents" ? current : path.join(current, "subagents");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function persistPreparedSession(sessionManager: SessionManager, cwd: string): SessionManager {
|
|
93
|
+
const sessionFile = sessionManager.getSessionFile()!;
|
|
94
|
+
mkdirSync(path.dirname(sessionFile), { recursive: true });
|
|
95
|
+
// SessionManager flushes itself once it holds an assistant message (a forked context);
|
|
96
|
+
// otherwise write the prepared entries so the child can be reopened.
|
|
97
|
+
if (!existsSync(sessionFile)) {
|
|
98
|
+
const entries = [sessionManager.getHeader(), ...sessionManager.getEntries()];
|
|
99
|
+
writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, { flag: "wx" });
|
|
100
|
+
}
|
|
101
|
+
return SessionManager.open(sessionFile, path.dirname(sessionFile), cwd);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveProjectTrust(cwd: string, current: ExtensionContext, agentDir: string): boolean {
|
|
105
|
+
const resolvedCwd = path.resolve(cwd);
|
|
106
|
+
if (resolvedCwd === path.resolve(current.cwd)) return current.isProjectTrusted();
|
|
107
|
+
if (!hasTrustRequiringProjectResources(resolvedCwd)) return true;
|
|
108
|
+
const saved = new ProjectTrustStore(agentDir).get(resolvedCwd);
|
|
109
|
+
if (saved !== null) return saved;
|
|
110
|
+
return SettingsManager.create(resolvedCwd, agentDir, { projectTrusted: false }).getDefaultProjectTrust() === "always";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function lastAnswer(session: AgentSession, before: number): { outcome: Outcome; result: string } {
|
|
114
|
+
const assistant = [...session.messages].reverse().find((message) => message.role === "assistant");
|
|
115
|
+
if (!assistant || session.getSessionStats().assistantMessages <= before) {
|
|
116
|
+
return { outcome: "failed", result: "The turn ended without an answer." };
|
|
117
|
+
}
|
|
118
|
+
const text = assistant.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
|
|
119
|
+
if (assistant.stopReason === "aborted") return { outcome: "aborted", result: text };
|
|
120
|
+
if (assistant.stopReason === "error") return { outcome: "failed", result: assistant.errorMessage || "Model request failed" };
|
|
121
|
+
return { outcome: "completed", result: text };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Takes this extension's messages left in the agent queues after a run settles. */
|
|
125
|
+
function takeQueued(agent: AgentSession["agent"]) {
|
|
126
|
+
// peekQueuedMessages returns one message in one-at-a-time mode; read the queues whole.
|
|
127
|
+
const queues = agent as unknown as Record<"steeringQueue" | "followUpQueue", { messages: QueuedMessage[] }>;
|
|
128
|
+
const steering = queues.steeringQueue.messages.slice();
|
|
129
|
+
const followUps = queues.followUpQueue.messages.slice();
|
|
130
|
+
agent.clearAllQueues();
|
|
131
|
+
const ours = (message: QueuedMessage): message is QueuedMessage & ReturnType<typeof customMessage> =>
|
|
132
|
+
message.role === "custom" && message.customType === MESSAGE_TYPE;
|
|
133
|
+
for (const message of steering) if (!ours(message)) agent.steer(message);
|
|
134
|
+
for (const message of followUps) if (!ours(message)) agent.followUp(message);
|
|
135
|
+
return [...steering, ...followUps].filter(ours);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface Deferred {
|
|
139
|
+
promise: Promise<void>;
|
|
140
|
+
resolve(): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function deferred(): Deferred {
|
|
144
|
+
let resolve!: () => void;
|
|
145
|
+
const promise = new Promise<void>((done) => { resolve = done; });
|
|
146
|
+
return { promise, resolve };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolves when `change` resolves, `ms` pass, or `signal` aborts. */
|
|
150
|
+
export async function waitForChange(change: Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
|
|
151
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
152
|
+
let onAbort: (() => void) | undefined;
|
|
153
|
+
try {
|
|
154
|
+
await Promise.race([
|
|
155
|
+
change,
|
|
156
|
+
new Promise<void>((resolve) => { timer = setTimeout(resolve, ms); }),
|
|
157
|
+
new Promise<void>((resolve) => {
|
|
158
|
+
onAbort = resolve;
|
|
159
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
160
|
+
}),
|
|
161
|
+
]);
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether `work` settles by `deadline`; a rejection passes through. */
|
|
169
|
+
export async function settlesBy(work: Promise<unknown>, deadline: number): Promise<boolean> {
|
|
170
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
171
|
+
try {
|
|
172
|
+
const settled = work.then(() => true);
|
|
173
|
+
// A rejection after the deadline has no one to report to.
|
|
174
|
+
settled.catch(() => {});
|
|
175
|
+
return await Promise.race([
|
|
176
|
+
settled,
|
|
177
|
+
new Promise<boolean>((resolve) => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now()), false); }),
|
|
178
|
+
]);
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function ended(input: InputRecord): boolean {
|
|
185
|
+
return input.state === "completed" || input.state === "failed" || input.state === "aborted";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface Input {
|
|
189
|
+
text: string;
|
|
190
|
+
/** The user's input from the Agent viewer. */
|
|
191
|
+
user?: boolean;
|
|
192
|
+
record?: InputRecord;
|
|
193
|
+
permit?: symbol;
|
|
194
|
+
schema?: unknown;
|
|
195
|
+
submitted?: { value: unknown };
|
|
196
|
+
done?: Deferred;
|
|
197
|
+
/** Ended by the owner's abort, so the owner is not notified. */
|
|
198
|
+
silent?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type Message = Pick<Input, "text" | "user">;
|
|
202
|
+
|
|
203
|
+
interface Turn {
|
|
204
|
+
inputs: Input[];
|
|
205
|
+
/** Messages that joined after the turn started and still need delivery. */
|
|
206
|
+
pending: Message[];
|
|
207
|
+
abort: AbortController;
|
|
208
|
+
started: boolean;
|
|
209
|
+
/** Whether the turn holds a scheduler slot. */
|
|
210
|
+
slot: boolean;
|
|
211
|
+
finished: boolean;
|
|
212
|
+
done: Deferred;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface Prepared {
|
|
216
|
+
sessionManager: SessionManager;
|
|
217
|
+
model: Model;
|
|
218
|
+
thinkingLevel: ThinkingLevel;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface Owned {
|
|
222
|
+
record: AgentRecord;
|
|
223
|
+
queue: Input[];
|
|
224
|
+
turn?: Turn;
|
|
225
|
+
session?: AgentSession;
|
|
226
|
+
loading?: Promise<AgentSession>;
|
|
227
|
+
prepared?: Prepared;
|
|
228
|
+
waiters: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface SpawnRequest {
|
|
232
|
+
name: string;
|
|
233
|
+
message: string;
|
|
234
|
+
cwd?: string;
|
|
235
|
+
context?: "fresh" | "fork";
|
|
236
|
+
model?: string;
|
|
237
|
+
thinkingLevel?: ThinkingLevel;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface WaitResult {
|
|
241
|
+
agent: AgentRecord;
|
|
242
|
+
input: InputRecord;
|
|
243
|
+
history: boolean;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface WaitOutcome {
|
|
247
|
+
results: WaitResult[];
|
|
248
|
+
pending: AgentRecord[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The Agents owned by one loaded Agent. */
|
|
252
|
+
export class Agents {
|
|
253
|
+
/** Waits in progress that keep this Agent's turn `waiting`. */
|
|
254
|
+
waiting = 0;
|
|
255
|
+
private agents = new Map<string, Owned>();
|
|
256
|
+
private change = deferred();
|
|
257
|
+
private closing = false;
|
|
258
|
+
private ctx?: ExtensionContext;
|
|
259
|
+
|
|
260
|
+
constructor(
|
|
261
|
+
private pi: ExtensionAPI,
|
|
262
|
+
private self: { id: string; rootId: string; scopeId: string; ownerId?: string; name(): string | undefined },
|
|
263
|
+
private agentDir: string,
|
|
264
|
+
private limits: Limits,
|
|
265
|
+
private extensions: string[],
|
|
266
|
+
/** Agents of a Program: kept in memory only, with results returned by `request()` and no notifications. */
|
|
267
|
+
private program = false,
|
|
268
|
+
) {}
|
|
269
|
+
|
|
270
|
+
setContext(ctx: ExtensionContext): void {
|
|
271
|
+
this.ctx = ctx;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
owned(): Array<Omit<Entry, "ownerId">> {
|
|
275
|
+
return [...this.agents.values()].map((agent) => ({
|
|
276
|
+
id: agent.record.id,
|
|
277
|
+
name: agent.record.name,
|
|
278
|
+
cwd: agent.record.cwd,
|
|
279
|
+
state: this.state(agent),
|
|
280
|
+
sessionFile: agent.record.sessionFile,
|
|
281
|
+
createdAt: agent.record.createdAt,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Whether an owned Agent has a turn or queued inputs. */
|
|
286
|
+
live(): boolean {
|
|
287
|
+
return [...this.agents.values()].some((agent) => agent.turn !== undefined || agent.queue.length > 0);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** An owned Agent's state, and its inputs pending behind its current or next turn. */
|
|
291
|
+
counts(id: string): { state: AgentState; pending: number } | undefined {
|
|
292
|
+
const agent = this.agents.get(id);
|
|
293
|
+
if (!agent) return undefined;
|
|
294
|
+
return { state: this.state(agent), pending: agent.queue.length };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private state(agent: Owned): AgentState {
|
|
298
|
+
if (!agent.turn) return "idle";
|
|
299
|
+
if (!agent.turn.slot) return "queued";
|
|
300
|
+
return (nodes.get(agent.record.id)?.agents.waiting ?? 0) > 0 ? "waiting" : "running";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** An owned Agent's loaded Session, or its saved messages while it is not loaded. */
|
|
304
|
+
conversation(id: string): {
|
|
305
|
+
session?: AgentSession;
|
|
306
|
+
messages: AgentSession["messages"];
|
|
307
|
+
cwd: string;
|
|
308
|
+
model?: { provider: string; modelId: string };
|
|
309
|
+
thinkingLevel?: string;
|
|
310
|
+
} | undefined {
|
|
311
|
+
const agent = this.agents.get(id);
|
|
312
|
+
if (!agent) return undefined;
|
|
313
|
+
const { session, record } = agent;
|
|
314
|
+
if (session) {
|
|
315
|
+
const model = session.model && { provider: session.model.provider, modelId: session.model.id };
|
|
316
|
+
return { session, messages: session.messages, cwd: record.cwd, model, thinkingLevel: session.thinkingLevel };
|
|
317
|
+
}
|
|
318
|
+
const manager = agent.prepared?.sessionManager ??
|
|
319
|
+
(existsSync(record.sessionFile) ? SessionManager.open(record.sessionFile, path.dirname(record.sessionFile), record.cwd) : undefined);
|
|
320
|
+
const context = manager?.buildSessionContext();
|
|
321
|
+
return { messages: context?.messages ?? [], cwd: record.cwd, model: context?.model ?? undefined, thinkingLevel: context?.thinkingLevel };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Sends the user's input to an owned Agent, as from Pi's editor: it has no sender header and no
|
|
326
|
+
* result. `steer` joins the current turn, or starts one when the Agent is idle.
|
|
327
|
+
*/
|
|
328
|
+
prompt(id: string, text: string, delivery: "followUp" | "steer"): void {
|
|
329
|
+
this.add(this.agent(id), { text, user: true, permit: this.reserve() }, delivery);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Loads an owned Agent's Session without starting a turn. */
|
|
333
|
+
open(id: string): Promise<AgentSession> {
|
|
334
|
+
return this.load(this.agent(id));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Stops an owned Agent's current turn and withdraws its queued inputs. */
|
|
338
|
+
async abortAgent(id: string): Promise<boolean> {
|
|
339
|
+
const agent = this.agents.get(id);
|
|
340
|
+
return agent ? this.abort(agent) : false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Resolves a visible target that this Agent owns. */
|
|
344
|
+
ownedTarget(target: string): Owned {
|
|
345
|
+
const entry = resolveTarget(this.self.scopeId, target);
|
|
346
|
+
const agent = this.agents.get(entry.id);
|
|
347
|
+
if (!agent) throw new Error(`${label(entry, this.ids())} is not owned by the caller.`);
|
|
348
|
+
return agent;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** IDs of the Agents visible to this Agent. */
|
|
352
|
+
ids(): string[] {
|
|
353
|
+
return visibleIds(this.self.scopeId, this.self.ownerId === undefined);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
spawn(request: SpawnRequest): { record: AgentRecord; queued: boolean } {
|
|
357
|
+
const permit = this.reserve();
|
|
358
|
+
let agent: Owned;
|
|
359
|
+
try {
|
|
360
|
+
agent = this.create(request);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
scheduler.releaseOutstanding(this.self.rootId, permit);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
const text = messageText({ id: this.self.id, name: this.self.name() }, true, "followUp", request.message, this.ids());
|
|
366
|
+
const queued = this.enqueue(agent, { text, permit, record: this.newRecord(agent) });
|
|
367
|
+
return { record: agent.record, queued };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Creates an idle Agent; `id` sets its ID. */
|
|
371
|
+
create(request: Omit<SpawnRequest, "message">, id?: string): Owned {
|
|
372
|
+
const name = request.name.trim();
|
|
373
|
+
const existing = [...this.agents.values()].find((agent) => agent.record.name === name);
|
|
374
|
+
if (existing) throw new Error(`Name "${name}" is already used by ${shortId(existing.record.id, this.ids())}.`);
|
|
375
|
+
const prepared = this.prepare(request, name, id);
|
|
376
|
+
const agent: Owned = {
|
|
377
|
+
record: {
|
|
378
|
+
id: prepared.sessionManager.getSessionId(),
|
|
379
|
+
name,
|
|
380
|
+
sessionFile: prepared.sessionManager.getSessionFile()!,
|
|
381
|
+
cwd: prepared.sessionManager.getCwd(),
|
|
382
|
+
createdAt: new Date().toISOString(),
|
|
383
|
+
inputs: [],
|
|
384
|
+
},
|
|
385
|
+
queue: [],
|
|
386
|
+
prepared,
|
|
387
|
+
waiters: 0,
|
|
388
|
+
};
|
|
389
|
+
this.agents.set(agent.record.id, agent);
|
|
390
|
+
return agent;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Sends an input from the owner and resolves when it ends. With a schema, the Agent must call
|
|
395
|
+
* `submit_result` with a matching value, which becomes the input's `value`; `text` tells it so.
|
|
396
|
+
*/
|
|
397
|
+
async request(id: string, text: string, delivery: "followUp" | "steer", schema?: unknown): Promise<InputRecord> {
|
|
398
|
+
const agent = this.agent(id);
|
|
399
|
+
const input: Input = { text, permit: this.reserve(), record: this.newRecord(agent), done: deferred() };
|
|
400
|
+
if (schema !== undefined) input.schema = schema;
|
|
401
|
+
this.add(agent, input, delivery);
|
|
402
|
+
await input.done!.promise;
|
|
403
|
+
return input.record!;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Records a value from `submit_result` for the Agent's current input with a schema. */
|
|
407
|
+
submit(id: string, value: unknown): void {
|
|
408
|
+
const input = this.agents.get(id)?.turn?.inputs.find((candidate) => candidate.schema !== undefined);
|
|
409
|
+
if (!input) throw new Error("No current input asks for a result.");
|
|
410
|
+
const args = validateToolArguments(
|
|
411
|
+
{ name: SUBMIT_RESULT_TOOL_NAME, description: "", parameters: { type: "object", properties: { value: input.schema }, required: ["value"] } as never },
|
|
412
|
+
{ type: "toolCall", id: "", name: SUBMIT_RESULT_TOOL_NAME, arguments: { value } as never },
|
|
413
|
+
) as { value: unknown };
|
|
414
|
+
input.submitted = { value: args.value };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean }): { queued: boolean } {
|
|
418
|
+
const agent = this.agent(id);
|
|
419
|
+
if (delivery === "write") {
|
|
420
|
+
this.write(agent, text);
|
|
421
|
+
return { queued: false };
|
|
422
|
+
}
|
|
423
|
+
// Notifications keep their owner informed even at the input limit.
|
|
424
|
+
const permit = options.notification ? undefined : this.reserve();
|
|
425
|
+
const input: Input = { text, permit, record: options.fromOwner && !options.notification ? this.newRecord(agent) : undefined };
|
|
426
|
+
return { queued: this.add(agent, input, delivery) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal): Promise<WaitOutcome> {
|
|
430
|
+
const selected = targets ?? [...this.agents.values()].filter((agent) =>
|
|
431
|
+
this.pending(agent) || agent.record.inputs.some((input) => ended(input) && !input.read));
|
|
432
|
+
const unique = [...new Set(selected)];
|
|
433
|
+
const replay = new Map(unique.map((agent) => [agent, history === 0
|
|
434
|
+
? []
|
|
435
|
+
: agent.record.inputs.filter((input) => ended(input) && input.read).slice(-history)]));
|
|
436
|
+
for (const agent of unique) agent.waiters += 1;
|
|
437
|
+
const blocking = unique.some((agent) => this.pending(agent));
|
|
438
|
+
const suspended = blocking && this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
|
|
439
|
+
if (blocking) this.markWaiting(1);
|
|
440
|
+
let returned = false;
|
|
441
|
+
try {
|
|
442
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
443
|
+
while (unique.some((agent) => this.pending(agent))) {
|
|
444
|
+
const remaining = deadline - Date.now();
|
|
445
|
+
if (remaining <= 0) break;
|
|
446
|
+
signal?.throwIfAborted();
|
|
447
|
+
await waitForChange(this.change.promise, remaining, signal);
|
|
448
|
+
}
|
|
449
|
+
signal?.throwIfAborted();
|
|
450
|
+
const results: WaitResult[] = [];
|
|
451
|
+
for (const agent of unique) {
|
|
452
|
+
for (const input of replay.get(agent)!) results.push({ agent: agent.record, input, history: true });
|
|
453
|
+
for (const input of agent.record.inputs) {
|
|
454
|
+
if (ended(input) && !input.read) results.push({ agent: agent.record, input, history: false });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
returned = true;
|
|
458
|
+
return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record) };
|
|
459
|
+
} finally {
|
|
460
|
+
if (blocking) this.markWaiting(-1);
|
|
461
|
+
if (suspended) await scheduler.resume(this.self.rootId, this.self.id, this.limits.maxConcurrent, signal);
|
|
462
|
+
for (const agent of unique) {
|
|
463
|
+
agent.waiters -= 1;
|
|
464
|
+
let notified = false;
|
|
465
|
+
for (const input of agent.record.inputs) {
|
|
466
|
+
if (!ended(input) || input.notified) continue;
|
|
467
|
+
if (returned) input.notified = notified = true;
|
|
468
|
+
else this.notifyLater(agent, input);
|
|
469
|
+
}
|
|
470
|
+
if (notified) this.persist(agent);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
markRead(results: WaitResult[]): void {
|
|
476
|
+
const changed = new Set<string>();
|
|
477
|
+
for (const { agent, input, history } of results) {
|
|
478
|
+
if (history || input.read) continue;
|
|
479
|
+
input.read = true;
|
|
480
|
+
changed.add(agent.id);
|
|
481
|
+
}
|
|
482
|
+
for (const id of changed) this.persist(this.agents.get(id)!);
|
|
483
|
+
if (changed.size > 0) treeChanged();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Stops the current turn and withdraws queued inputs; returns whether there was anything to stop.
|
|
488
|
+
* A turn that has not stopped by `deadline` is abandoned: its inputs end as aborted, and this throws.
|
|
489
|
+
* `silent` marks an abort by the owner, which is not notified of the inputs it ends.
|
|
490
|
+
*/
|
|
491
|
+
async abort(agent: Owned, { silent = false, deadline = Date.now() + SHUTDOWN_TIMEOUT_MS } = {}): Promise<boolean> {
|
|
492
|
+
const turn = agent.turn;
|
|
493
|
+
if (!turn && agent.queue.length === 0) return false;
|
|
494
|
+
if (silent) for (const input of [...agent.queue, ...(turn?.inputs ?? [])]) input.silent = true;
|
|
495
|
+
for (const input of agent.queue.splice(0)) this.end(agent, input, "aborted", "");
|
|
496
|
+
this.changed();
|
|
497
|
+
if (turn) {
|
|
498
|
+
turn.abort.abort();
|
|
499
|
+
const stopping = (async () => {
|
|
500
|
+
if (turn.started && agent.session) {
|
|
501
|
+
agent.session.abortCompaction();
|
|
502
|
+
agent.session.abortRetry();
|
|
503
|
+
await agent.session.abort();
|
|
504
|
+
}
|
|
505
|
+
await turn.done.promise;
|
|
506
|
+
})();
|
|
507
|
+
if (!await settlesBy(stopping, deadline)) {
|
|
508
|
+
this.finish(agent, turn, { outcome: "aborted", result: "" });
|
|
509
|
+
throw new Error(`Agent ${label(agent.record, this.ids())} did not stop within the timeout and may still be running.`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
restore(ctx: ExtensionContext): void {
|
|
516
|
+
const records = new Map<string, AgentRecord>();
|
|
517
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
518
|
+
if (entry.type !== "custom" || entry.customType !== AGENT_ENTRY) continue;
|
|
519
|
+
const record = entry.data as AgentRecord;
|
|
520
|
+
records.set(record.id, record);
|
|
521
|
+
}
|
|
522
|
+
for (const record of records.values()) {
|
|
523
|
+
const agent: Owned = { record, queue: [], waiters: 0 };
|
|
524
|
+
this.agents.set(record.id, agent);
|
|
525
|
+
if (!record.inputs.some((input) => !ended(input))) continue;
|
|
526
|
+
for (const input of record.inputs) {
|
|
527
|
+
if (ended(input)) continue;
|
|
528
|
+
input.state = "aborted";
|
|
529
|
+
input.result = "The process exited before this input ended.";
|
|
530
|
+
input.notified = true;
|
|
531
|
+
}
|
|
532
|
+
this.persist(agent);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Takes the Agents offline; aborting and shutting down share one timeout, after which work is abandoned and this throws. */
|
|
537
|
+
async shutdown(): Promise<void> {
|
|
538
|
+
this.closing = true;
|
|
539
|
+
const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS;
|
|
540
|
+
const agents = [...this.agents.values()];
|
|
541
|
+
const aborted = await Promise.allSettled(agents.map((agent) => this.abort(agent, { deadline })));
|
|
542
|
+
const closed = await Promise.allSettled(agents.map(async (agent) => {
|
|
543
|
+
const session = agent.session ?? await agent.loading?.catch(() => undefined);
|
|
544
|
+
if (!session) return;
|
|
545
|
+
try {
|
|
546
|
+
if (!await settlesBy(session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }), deadline)) {
|
|
547
|
+
throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
|
|
548
|
+
}
|
|
549
|
+
} finally {
|
|
550
|
+
session.dispose();
|
|
551
|
+
agent.session = undefined;
|
|
552
|
+
}
|
|
553
|
+
}));
|
|
554
|
+
const errors = [...aborted, ...closed].flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
555
|
+
if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private write(agent: Owned, text: string): void {
|
|
559
|
+
const message = customMessage(text, "write");
|
|
560
|
+
const session = agent.session;
|
|
561
|
+
if (session?.isStreaming) {
|
|
562
|
+
session.agent.steer({ role: "custom", ...message, timestamp: Date.now() });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (session) {
|
|
566
|
+
void session.sendCustomMessage(message, { triggerTurn: false });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// Loading an idle Agent does not start a turn.
|
|
570
|
+
void this.load(agent).then(
|
|
571
|
+
(loaded) => loaded.sendCustomMessage(message, { triggerTurn: false }),
|
|
572
|
+
(error) => console.warn(`[pi-agents] write to ${agent.record.id} failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private enqueue(agent: Owned, input: Input): boolean {
|
|
577
|
+
const queued = !agent.turn && !scheduler.hasCapacity(this.self.rootId, this.limits.maxConcurrent);
|
|
578
|
+
agent.queue.push(input);
|
|
579
|
+
this.persist(agent);
|
|
580
|
+
this.changed();
|
|
581
|
+
this.pump(agent);
|
|
582
|
+
return queued;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** A steer joins the current turn; other inputs queue. Returns whether the input waits for a slot. */
|
|
586
|
+
private add(agent: Owned, input: Input, delivery: "followUp" | "steer"): boolean {
|
|
587
|
+
if (delivery !== "steer" || !agent.turn) return this.enqueue(agent, input);
|
|
588
|
+
this.join(agent, agent.turn, input);
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private join(agent: Owned, turn: Turn, input: Input): void {
|
|
593
|
+
turn.inputs.push(input);
|
|
594
|
+
if (input.record && turn.started) input.record.state = "running";
|
|
595
|
+
this.persist(agent);
|
|
596
|
+
this.changed();
|
|
597
|
+
if (!turn.started) return;
|
|
598
|
+
if (agent.session?.isStreaming) {
|
|
599
|
+
agent.session.agent.steer({ role: "custom", ...customMessage(input.text, "steer", undefined, input.user), timestamp: Date.now() });
|
|
600
|
+
} else {
|
|
601
|
+
turn.pending.push({ text: input.text, user: input.user });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private pump(agent: Owned): void {
|
|
606
|
+
if (agent.turn || this.closing) return;
|
|
607
|
+
const head = agent.queue.shift();
|
|
608
|
+
if (!head) return;
|
|
609
|
+
const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, finished: false, done: deferred() };
|
|
610
|
+
agent.turn = turn;
|
|
611
|
+
void this.runTurn(agent, turn);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async runTurn(agent: Owned, turn: Turn): Promise<void> {
|
|
615
|
+
let answer: { outcome: Outcome; result: string } = { outcome: "aborted", result: "" };
|
|
616
|
+
try {
|
|
617
|
+
await scheduler.acquireQueued(this.self.rootId, agent.record.id, this.limits.maxConcurrent, turn.abort.signal);
|
|
618
|
+
turn.slot = true;
|
|
619
|
+
const session = await this.load(agent);
|
|
620
|
+
turn.abort.signal.throwIfAborted();
|
|
621
|
+
const before = session.getSessionStats().assistantMessages;
|
|
622
|
+
turn.started = true;
|
|
623
|
+
for (const input of turn.inputs) if (input.record) input.record.state = "running";
|
|
624
|
+
this.persist(agent);
|
|
625
|
+
this.changed();
|
|
626
|
+
let batch: Message[] = turn.inputs.map(({ text, user }) => ({ text, user }));
|
|
627
|
+
while (batch.length > 0 && !turn.abort.signal.aborted) {
|
|
628
|
+
for (const { text, user } of batch.slice(0, -1)) await session.sendCustomMessage(customMessage(text, "steer", undefined, user), { triggerTurn: false });
|
|
629
|
+
const last = batch.at(-1)!;
|
|
630
|
+
await session.sendCustomMessage(customMessage(last.text, "steer", undefined, last.user), { triggerTurn: true });
|
|
631
|
+
await session.waitForIdle();
|
|
632
|
+
const leftover = takeQueued(session.agent);
|
|
633
|
+
for (const message of leftover) {
|
|
634
|
+
if (message.details.delivery === "write") await session.sendCustomMessage(message, { triggerTurn: false });
|
|
635
|
+
}
|
|
636
|
+
batch = [
|
|
637
|
+
...turn.pending.splice(0),
|
|
638
|
+
...leftover.flatMap(({ content, details }) => details.delivery === "write" ? [] : [{ text: content, user: details.user }]),
|
|
639
|
+
];
|
|
640
|
+
}
|
|
641
|
+
const last = lastAnswer(session, before);
|
|
642
|
+
answer = !turn.abort.signal.aborted ? last : { outcome: "aborted", result: last.outcome === "failed" ? "" : last.result };
|
|
643
|
+
} catch (error) {
|
|
644
|
+
if (!turn.abort.signal.aborted) answer = { outcome: "failed", result: error instanceof Error ? error.message : String(error) };
|
|
645
|
+
} finally {
|
|
646
|
+
this.finish(agent, turn, answer);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Ends a turn once: when it returns, or when an abort abandons it. */
|
|
651
|
+
private finish(agent: Owned, turn: Turn, answer: { outcome: Outcome; result: string }): void {
|
|
652
|
+
if (turn.finished) return;
|
|
653
|
+
turn.finished = true;
|
|
654
|
+
if (turn.slot) scheduler.release(this.self.rootId, agent.record.id);
|
|
655
|
+
if (agent.turn === turn) agent.turn = undefined;
|
|
656
|
+
for (const input of turn.inputs) this.end(agent, input, answer.outcome, answer.result);
|
|
657
|
+
turn.done.resolve();
|
|
658
|
+
this.changed();
|
|
659
|
+
this.pump(agent);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private end(agent: Owned, input: Input, outcome: Outcome, result: string): void {
|
|
663
|
+
if (input.permit) scheduler.releaseOutstanding(this.self.rootId, input.permit);
|
|
664
|
+
input.permit = undefined;
|
|
665
|
+
const record = input.record;
|
|
666
|
+
if (!record || ended(record)) return;
|
|
667
|
+
if (input.schema !== undefined && outcome !== "aborted") {
|
|
668
|
+
if (input.submitted) {
|
|
669
|
+
outcome = "completed";
|
|
670
|
+
record.value = input.submitted.value;
|
|
671
|
+
} else if (outcome === "completed") {
|
|
672
|
+
outcome = "failed";
|
|
673
|
+
result = `The turn ended without calling ${SUBMIT_RESULT_TOOL_NAME}.`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
record.state = outcome;
|
|
677
|
+
record.result = result;
|
|
678
|
+
if (input.silent) record.notified = true;
|
|
679
|
+
this.persist(agent);
|
|
680
|
+
input.done?.resolve();
|
|
681
|
+
if (agent.waiters === 0 && !input.silent) this.notifyLater(agent, record);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private notifyLater(agent: Owned, record: InputRecord): void {
|
|
685
|
+
if (this.program) return;
|
|
686
|
+
// The tree stays busy until the notification is delivered.
|
|
687
|
+
const release = trackTreeWork(this.self.rootId);
|
|
688
|
+
queueMicrotask(() => {
|
|
689
|
+
try {
|
|
690
|
+
if (this.closing || record.notified || agent.waiters > 0) return;
|
|
691
|
+
record.notified = true;
|
|
692
|
+
this.persist(agent);
|
|
693
|
+
this.notify(`Agent ${label(agent.record, this.ids())} ${record.state}.`);
|
|
694
|
+
} finally {
|
|
695
|
+
release();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Delivers a notification to this Agent as a steer. */
|
|
701
|
+
notify(text: string): void {
|
|
702
|
+
try {
|
|
703
|
+
deliver(undefined, { id: this.self.id, name: this.self.name(), ownerId: this.self.ownerId, cwd: "", state: "running" }, "steer", text);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
console.warn(`[pi-agents] notification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Runs `work` while an owned caller gives up its slot, and takes one again before returning.
|
|
711
|
+
* `waiting` marks the caller as waiting meanwhile.
|
|
712
|
+
*/
|
|
713
|
+
async whileSuspended<T>(work: () => Promise<T>, signal?: AbortSignal, waiting = false): Promise<T> {
|
|
714
|
+
const suspended = this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
|
|
715
|
+
if (waiting) this.markWaiting(1);
|
|
716
|
+
try {
|
|
717
|
+
return await work();
|
|
718
|
+
} finally {
|
|
719
|
+
if (waiting) this.markWaiting(-1);
|
|
720
|
+
if (suspended) await scheduler.resume(this.self.rootId, this.self.id, this.limits.maxConcurrent, signal);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private markWaiting(delta: number): void {
|
|
725
|
+
this.waiting += delta;
|
|
726
|
+
treeChanged();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private agent(id: string): Owned {
|
|
730
|
+
const agent = this.agents.get(id);
|
|
731
|
+
if (!agent) throw new Error(`Agent ${id} is not owned by ${this.self.id}.`);
|
|
732
|
+
// Inputs after shutdown would never run.
|
|
733
|
+
if (this.closing) throw new Error(`Agent ${label(agent.record, this.ids())} is offline.`);
|
|
734
|
+
return agent;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private pending(agent: Owned): boolean {
|
|
738
|
+
return agent.record.inputs.some((input) => !ended(input));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private newRecord(agent: Owned): InputRecord {
|
|
742
|
+
const record: InputRecord = { id: `${agent.record.id}:${agent.record.inputs.length + 1}`, state: "queued", read: false, notified: false };
|
|
743
|
+
agent.record.inputs.push(record);
|
|
744
|
+
return record;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private reserve(): symbol {
|
|
748
|
+
const permit = scheduler.reserveOutstanding(this.self.rootId, this.limits.maxOutstanding);
|
|
749
|
+
if (!permit) throw new Error(`Input rejected: ${this.limits.maxOutstanding} inputs have not ended. Wait for results or abort an Agent.`);
|
|
750
|
+
return permit;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private persist(agent: Owned): void {
|
|
754
|
+
if (this.program) return;
|
|
755
|
+
if (this.closing && !agent.record.inputs.some((input) => !ended(input))) return;
|
|
756
|
+
this.pi.appendEntry(AGENT_ENTRY, agent.record);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private changed(): void {
|
|
760
|
+
const previous = this.change;
|
|
761
|
+
this.change = deferred();
|
|
762
|
+
previous.resolve();
|
|
763
|
+
treeChanged();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private requireContext(): ExtensionContext {
|
|
767
|
+
if (!this.ctx) throw new Error("pi-agents is not initialized");
|
|
768
|
+
return this.ctx;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private prepare(request: Omit<SpawnRequest, "message">, name: string, id?: string): Prepared {
|
|
772
|
+
const ctx = this.requireContext();
|
|
773
|
+
let model = ctx.model;
|
|
774
|
+
if (request.model !== undefined) {
|
|
775
|
+
const separator = request.model.indexOf("/");
|
|
776
|
+
model = separator > 0
|
|
777
|
+
? ctx.modelRegistry.find(request.model.slice(0, separator), request.model.slice(separator + 1))
|
|
778
|
+
: undefined;
|
|
779
|
+
if (!model) throw new Error(`Unknown model "${request.model}". Use provider/modelId.`);
|
|
780
|
+
}
|
|
781
|
+
if (!model) throw new Error("Cannot spawn an Agent without a model.");
|
|
782
|
+
const thinkingLevel = request.thinkingLevel ?? this.pi.getThinkingLevel();
|
|
783
|
+
// In a pi-env execution environment, the Agent runs in the same environment and `cwd` is a path there.
|
|
784
|
+
const envEntry = ctx.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === "env-state").pop();
|
|
785
|
+
const envState = envEntry?.type === "custom" ? structuredClone(envEntry.data) as { environment?: string | null } : undefined;
|
|
786
|
+
const envTarget = envState?.environment?.match(/^((?:docker|ssh):[^:]+):(.+)$/);
|
|
787
|
+
if (envTarget && request.cwd !== undefined) envState!.environment = `${envTarget[1]}:${path.posix.resolve(envTarget[2], request.cwd)}`;
|
|
788
|
+
const cwd = envTarget ? ctx.cwd : path.resolve(ctx.cwd, request.cwd ?? ".");
|
|
789
|
+
const sessionManager = SessionManager.create(cwd, childSessionDirectory(cwd, ctx.cwd, ctx.sessionManager.getSessionDir(), this.agentDir), {
|
|
790
|
+
id: id ?? randomUUID(),
|
|
791
|
+
parentSession: ctx.sessionManager.getSessionFile(),
|
|
792
|
+
});
|
|
793
|
+
// Each of a Program's Agents heads its own scope, so it sees only itself and the Agents under it.
|
|
794
|
+
const scopeId = this.program ? sessionManager.getSessionId() : this.self.scopeId;
|
|
795
|
+
sessionManager.appendCustomEntry(TREE_ENTRY, { rootId: this.self.rootId, ownerId: this.self.id, scopeId } satisfies TreeMetadata);
|
|
796
|
+
sessionManager.appendSessionInfo(name);
|
|
797
|
+
sessionManager.appendModelChange(model.provider, model.id);
|
|
798
|
+
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
|
799
|
+
if (envState) sessionManager.appendCustomEntry("env-state", envState);
|
|
800
|
+
if (request.context === "fork") {
|
|
801
|
+
// Tools run after Pi persists the assistant message that called them; fork before it
|
|
802
|
+
// so the child never inherits a tool call without its result.
|
|
803
|
+
const messages = (ctx.sessionManager as SessionManager).buildSessionContext().messages;
|
|
804
|
+
const last = messages.at(-1);
|
|
805
|
+
const forked = last?.role === "assistant" && last.content.some((part) => part.type === "toolCall") ? messages.slice(0, -1) : messages;
|
|
806
|
+
for (const message of forked) sessionManager.appendMessage(structuredClone(message) as Parameters<SessionManager["appendMessage"]>[0]);
|
|
807
|
+
}
|
|
808
|
+
return { sessionManager: persistPreparedSession(sessionManager, cwd), model, thinkingLevel };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private load(agent: Owned): Promise<AgentSession> {
|
|
812
|
+
if (agent.session) return Promise.resolve(agent.session);
|
|
813
|
+
agent.loading ??= this.createSession(agent).then((session) => {
|
|
814
|
+
agent.session = session;
|
|
815
|
+
agent.prepared = undefined;
|
|
816
|
+
return session;
|
|
817
|
+
}).finally(() => { agent.loading = undefined; });
|
|
818
|
+
return agent.loading;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private async createSession(agent: Owned): Promise<AgentSession> {
|
|
822
|
+
const ctx = this.requireContext();
|
|
823
|
+
const { cwd, sessionFile } = agent.record;
|
|
824
|
+
const settingsManager = SettingsManager.create(cwd, this.agentDir, { projectTrusted: resolveProjectTrust(cwd, ctx, this.agentDir) });
|
|
825
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
826
|
+
cwd,
|
|
827
|
+
agentDir: this.agentDir,
|
|
828
|
+
settingsManager,
|
|
829
|
+
noExtensions: true,
|
|
830
|
+
additionalExtensionPaths: [EXTENSION_PATH, ...this.extensions],
|
|
831
|
+
});
|
|
832
|
+
await resourceLoader.reload();
|
|
833
|
+
const loadErrors = resourceLoader.getExtensions().errors;
|
|
834
|
+
if (loadErrors.length > 0) throw new Error(`Extension loading failed: ${loadErrors.map((error) => `${error.path}: ${error.error}`).join("; ")}`);
|
|
835
|
+
if (!agent.prepared && !existsSync(sessionFile)) throw new Error(`Session file is missing: ${sessionFile}`);
|
|
836
|
+
const sessionManager = agent.prepared?.sessionManager ?? SessionManager.open(sessionFile, path.dirname(sessionFile), cwd);
|
|
837
|
+
const { session, modelFallbackMessage } = await createAgentSession({
|
|
838
|
+
cwd,
|
|
839
|
+
agentDir: this.agentDir,
|
|
840
|
+
// Share the owner's model runtime so extension-registered providers and auth carry over.
|
|
841
|
+
modelRuntime: (ctx.modelRegistry as unknown as { runtime: CreateOptions["modelRuntime"] }).runtime,
|
|
842
|
+
settingsManager,
|
|
843
|
+
resourceLoader,
|
|
844
|
+
sessionManager,
|
|
845
|
+
model: agent.prepared?.model,
|
|
846
|
+
thinkingLevel: agent.prepared?.thinkingLevel,
|
|
847
|
+
tools: [
|
|
848
|
+
...this.pi.getActiveTools().filter((name) => name !== SUBMIT_RESULT_TOOL_NAME),
|
|
849
|
+
...(this.program ? [SUBMIT_RESULT_TOOL_NAME] : []),
|
|
850
|
+
],
|
|
851
|
+
sessionStartEvent: { type: "session_start", reason: agent.prepared ? "new" : "resume" },
|
|
852
|
+
});
|
|
853
|
+
if (!session.model) {
|
|
854
|
+
session.dispose();
|
|
855
|
+
throw new Error(modelFallbackMessage ?? "No model available.");
|
|
856
|
+
}
|
|
857
|
+
const errors: ExtensionError[] = [];
|
|
858
|
+
const unsupported = async (): Promise<never> => { throw new Error("Session control is not supported in owned Agents."); };
|
|
859
|
+
try {
|
|
860
|
+
await session.bindExtensions({
|
|
861
|
+
mode: "print",
|
|
862
|
+
onError: (error) => errors.push(error),
|
|
863
|
+
commandContextActions: {
|
|
864
|
+
waitForIdle: () => session.waitForIdle(),
|
|
865
|
+
newSession: unsupported,
|
|
866
|
+
switchSession: unsupported,
|
|
867
|
+
fork: unsupported,
|
|
868
|
+
navigateTree: unsupported,
|
|
869
|
+
reload: unsupported,
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
if (errors.length > 0) {
|
|
873
|
+
throw new Error(`Extension initialization failed: ${errors.map((error) => `${error.extensionPath} (${error.event}): ${error.error}`).join("; ")}`);
|
|
874
|
+
}
|
|
875
|
+
} catch (error) {
|
|
876
|
+
try { await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); }
|
|
877
|
+
finally { session.dispose(); }
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
880
|
+
return session;
|
|
881
|
+
}
|
|
882
|
+
}
|