@evandu/super-pi 0.1.94 → 0.1.96
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/agents/super-pi-task/core.ts +6 -5
- package/agents/super-pi-task/index.ts +2 -1
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +3 -3
- package/dist/client/assets/{index-zbxFjL-O.js → index-gjLqMWYT.js} +2 -2
- package/dist/client/index.html +1 -1
- package/dist/extension.js +13 -3
- package/dist/server/agents.d.ts +18 -0
- package/dist/server/agents.js +39 -0
- package/dist/server/application/run-service.d.ts +47 -3
- package/dist/server/application/run-service.js +158 -88
- package/dist/server/capability.d.ts +28 -12
- package/dist/server/capability.js +54 -20
- package/dist/server/http-server.js +14 -6
- package/dist/server/index.js +10 -1
- package/dist/server/pi-command.d.ts +29 -1
- package/dist/server/pi-command.js +82 -5
- package/dist/server/pi-session-events.d.ts +111 -0
- package/dist/server/pi-session-events.js +291 -0
- package/dist/server/pi-session-pool.d.ts +166 -0
- package/dist/server/pi-session-pool.js +262 -0
- package/dist/server/pi-session.d.ts +204 -0
- package/dist/server/pi-session.js +226 -0
- package/dist/server/runtime.d.ts +262 -29
- package/dist/server/runtime.js +664 -60
- package/dist/server/store.js +4 -4
- package/dist/server/ws-server.js +5 -4
- package/package.json +7 -3
- package/scripts/patch-brace-expansion.mjs +179 -0
package/dist/server/runtime.js
CHANGED
|
@@ -8,9 +8,16 @@ import { mkdir, stat } from "node:fs/promises";
|
|
|
8
8
|
import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
9
9
|
export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
10
10
|
import { resolve, join } from "node:path";
|
|
11
|
-
import { agentSpawnArgs, readAgents } from "./agents.js";
|
|
11
|
+
import { agentSpawnArgs, readAgents, toolSelection, writeAgentPrompt } from "./agents.js";
|
|
12
12
|
import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
|
|
13
13
|
import { resolvePiCommand, shellQuote } from "./pi-command.js";
|
|
14
|
+
import { PiSessionPool, repositoryRootFrom } from "./pi-session-pool.js";
|
|
15
|
+
import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, TurnQueue } from "./pi-session-events.js";
|
|
16
|
+
// The queue/steer recognisers live in pi-session-events.ts, where the SESSION path's bridge can use
|
|
17
|
+
// them; the one-shot drain loop below reads the same records through the same two functions, so both
|
|
18
|
+
// paths recognise a delivery identically. Re-exported because they were part of this module's surface.
|
|
19
|
+
export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
|
|
20
|
+
import { createPiSession, piSdkAvailable, superPiExtensionPaths } from "./pi-session.js";
|
|
14
21
|
import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, rosterPayload, } from "./task-tool.js";
|
|
15
22
|
import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
|
|
16
23
|
import { loadConversationImages } from "./upload.js";
|
|
@@ -43,54 +50,6 @@ export class WelcomeRuntime {
|
|
|
43
50
|
return { source: "unavailable", models: [], levels: [], fetchedAt: new Date().toISOString(), error: "Welcome runtime has no Pi session to ask" };
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
|
-
/**
|
|
47
|
-
* One record off Pi's JSONL stdout. Deliberately a loose shape with optional fields: Pi emits a wide,
|
|
48
|
-
* open-ended family of session events, and a type that enumerated all of them would break on the next
|
|
49
|
-
* Pi release rather than on the next behaviour change.
|
|
50
|
-
*
|
|
51
|
-
* Exported so another consumer of `RpcChannel` - the sub-agent supervisor - can read the same records
|
|
52
|
-
* through the same channel instead of writing a second JSONL reader. Every field below is optional, so
|
|
53
|
-
* a reader must check before using one.
|
|
54
|
-
*/
|
|
55
|
-
/**
|
|
56
|
-
* Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
|
|
57
|
-
*
|
|
58
|
-
* Filters to real strings rather than passing the arrays through, so a malformed snapshot cannot put
|
|
59
|
-
* objects into the browser's DOM or into a log line that is supposed to be readable - the same rule
|
|
60
|
-
* `textList` already applies to Extension UI options. Returns undefined for the wrong `type` too, so the
|
|
61
|
-
* drain loop can keep using it as the recogniser.
|
|
62
|
-
*/
|
|
63
|
-
export function describePiQueue(record) {
|
|
64
|
-
if (record.type !== "queue_update")
|
|
65
|
-
return undefined;
|
|
66
|
-
return { steering: textList(record.steering) ?? [], followUp: textList(record.followUp) ?? [] };
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
|
|
70
|
-
*
|
|
71
|
-
* A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
|
|
72
|
-
* opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
|
|
73
|
-
*
|
|
74
|
-
* Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
|
|
75
|
-
* back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
|
|
76
|
-
* only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
|
|
77
|
-
* cannot tell two identical messages apart either, which is why the caller resolves a delivery by
|
|
78
|
-
* position and not by comparing this.
|
|
79
|
-
*/
|
|
80
|
-
export function describeDeliveredUserMessage(record) {
|
|
81
|
-
if (record.type !== "message_start")
|
|
82
|
-
return undefined;
|
|
83
|
-
const message = record.message;
|
|
84
|
-
if (!message || typeof message !== "object" || message.role !== "user")
|
|
85
|
-
return undefined;
|
|
86
|
-
const content = message.content;
|
|
87
|
-
if (!Array.isArray(content))
|
|
88
|
-
return undefined;
|
|
89
|
-
const parts = content
|
|
90
|
-
.map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : undefined))
|
|
91
|
-
.filter((part) => part !== undefined);
|
|
92
|
-
return parts.length ? parts.join("\n") : undefined;
|
|
93
|
-
}
|
|
94
53
|
const NOTIFY_TYPES = new Set(["info", "warning", "error"]);
|
|
95
54
|
const PLACEMENTS = new Set(["aboveEditor", "belowEditor"]);
|
|
96
55
|
const text = (value) => (typeof value === "string" ? value : undefined);
|
|
@@ -287,6 +246,43 @@ export class PiRpcRuntime {
|
|
|
287
246
|
knowledge;
|
|
288
247
|
setKnowledge(knowledge) { this.knowledge = knowledge; }
|
|
289
248
|
runs = new Map();
|
|
249
|
+
/**
|
|
250
|
+
* THE LIVE SESSIONS, one per conversation, alive across turns.
|
|
251
|
+
*
|
|
252
|
+
* `runs` is the OLD registry - a child that exists only while a turn is running - and it still
|
|
253
|
+
* describes the one-shot paths (memory review, knowledge rerank, catalog probe), which keep their
|
|
254
|
+
* own throwaway children. The main conversation path does NOT use it: a conversation's session lives
|
|
255
|
+
* in the pool, and a turn is a run ON that session rather than a process OF its own.
|
|
256
|
+
*/
|
|
257
|
+
sessionPool;
|
|
258
|
+
/**
|
|
259
|
+
* The turn currently running on each conversation's session.
|
|
260
|
+
*
|
|
261
|
+
* This is the piece the old design did not need, because a turn WAS a process and the process
|
|
262
|
+
* registry said everything. With a session that outlives its turns, the host has to know which turn
|
|
263
|
+
* is on the session RIGHT NOW - that is what an abort targets, and what a steer is delivered into.
|
|
264
|
+
*/
|
|
265
|
+
activeTurns = new Map();
|
|
266
|
+
/**
|
|
267
|
+
* THE CURRENT TURN'S EXTENSION-UI HOST, per conversation.
|
|
268
|
+
*
|
|
269
|
+
* Pi's blocking UI questions (`select`/`confirm`/`input`/`editor`) are answered by a host, and the
|
|
270
|
+
* host is a per-TURN thing - `run-service` builds one per `answer()` call - while the UI context the
|
|
271
|
+
* session's extensions talk to is bound ONCE per session. This map is the bridge between the two
|
|
272
|
+
* lifetimes: a turn registers its host here for exactly its own duration, and the session's bound
|
|
273
|
+
* context reads through it at call time. A question asked while no turn is registered gets the honest
|
|
274
|
+
* answer - `cancelled` - never a default, which is the same rule the RPC path's no-host branch follows.
|
|
275
|
+
*/
|
|
276
|
+
sessionHosts = new Map();
|
|
277
|
+
/** The sessions whose extension runner already has the UI bridge bound. */
|
|
278
|
+
uiBoundSessions = new WeakSet();
|
|
279
|
+
/**
|
|
280
|
+
* The session-file path last reported for a conversation, so `onSessionFile` fires when it CHANGES
|
|
281
|
+
* rather than once per turn. The path is stable for the life of a session; what changes it is a
|
|
282
|
+
* rebuild (a revert, an eviction, a fingerprint change), which is exactly when the cached locator
|
|
283
|
+
* needs refreshing.
|
|
284
|
+
*/
|
|
285
|
+
reportedSessionFiles = new Map();
|
|
290
286
|
/**
|
|
291
287
|
* `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
|
|
292
288
|
*
|
|
@@ -305,6 +301,18 @@ export class PiRpcRuntime {
|
|
|
305
301
|
pendingCatalogs = new Map();
|
|
306
302
|
/** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
|
|
307
303
|
warnedMissingExtension = false;
|
|
304
|
+
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
305
|
+
warnedMissingSdk = false;
|
|
306
|
+
/**
|
|
307
|
+
* How a CONVERSATION runs: on a persistent SDK session, or by spawning a Pi for every turn.
|
|
308
|
+
*
|
|
309
|
+
* is the slower one, and it exists for the test suite: those suites drive a fake Pi that
|
|
310
|
+
* speaks the RPC protocol over a pipe, and a program is not a library - the session path cannot run
|
|
311
|
+
* it. It is an explicit request rather than something inferred from which Pi happens to be named,
|
|
312
|
+
* because inferring it is what made the Pi extension start conversations on the slow path without
|
|
313
|
+
* saying why.
|
|
314
|
+
*/
|
|
315
|
+
sessionMode;
|
|
308
316
|
/**
|
|
309
317
|
* Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
|
|
310
318
|
* per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
|
|
@@ -318,6 +326,13 @@ export class PiRpcRuntime {
|
|
|
318
326
|
*/
|
|
319
327
|
pi;
|
|
320
328
|
canSteer(conversationId) {
|
|
329
|
+
// A steer is only meaningful while a turn is RUNNING. A live session with nothing running is not a
|
|
330
|
+
// steer target: there is no current answer for the message to be inserted into, and the transcript's
|
|
331
|
+
// split-on-delivery model would have nothing to split. So this asks about the TURN, not about the
|
|
332
|
+
// process - which is the change the persistent session forces, and the reason it is stated here
|
|
333
|
+
// rather than left to a caller that used to infer it from a live child.
|
|
334
|
+
if (this.activeTurns.has(conversationId))
|
|
335
|
+
return true;
|
|
321
336
|
const run = this.runs.get(conversationId);
|
|
322
337
|
return Boolean(run && run.child.exitCode === null && !run.child.killed && !run.child.stdin.destroyed);
|
|
323
338
|
}
|
|
@@ -347,6 +362,10 @@ export class PiRpcRuntime {
|
|
|
347
362
|
* older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
|
|
348
363
|
* from "configured wrong". When it is absent the tool still registers and the extension refuses in
|
|
349
364
|
* words, which is what `refuseTodo` in core.ts is for.
|
|
365
|
+
*
|
|
366
|
+
* `sessionFactory` is the TEST SEAM for the session path: the pool builds sessions through it instead
|
|
367
|
+
* of `createPiSession`, so a test can drive `answer()` against a fake `PiSessionLike` and assert on
|
|
368
|
+
* what the turn did with it - the same role the factory parameter plays in `PiSessionPool` itself.
|
|
350
369
|
*/
|
|
351
370
|
options = {}) {
|
|
352
371
|
this.sessionDir = sessionDir;
|
|
@@ -355,6 +374,71 @@ export class PiRpcRuntime {
|
|
|
355
374
|
this.pi = resolvePiCommand({ command });
|
|
356
375
|
if (!this.pi.found)
|
|
357
376
|
console.warn(`PiRpcRuntime: ${this.pi.reason}`);
|
|
377
|
+
this.sessionMode = this.options.sessionMode
|
|
378
|
+
?? (process.env.SUPER_PI_SESSION_MODE === "oneshot" ? "oneshot" : "sdk");
|
|
379
|
+
this.sessionPool = new PiSessionPool(sessionDir, repositoryRootFrom(), undefined, (conversationId) => {
|
|
380
|
+
// A session's credential dies with the session. The session is what HELD the token - its
|
|
381
|
+
// extension read it out of the environment when it was loaded - so a session that is gone must
|
|
382
|
+
// not leave a usable token behind.
|
|
383
|
+
releaseCapability(conversationId);
|
|
384
|
+
// The session also held this conversation's UI bridge and session-file cache; both are facts
|
|
385
|
+
// ABOUT that session and stop being true with it.
|
|
386
|
+
this.sessionHosts.delete(conversationId);
|
|
387
|
+
this.reportedSessionFiles.delete(conversationId);
|
|
388
|
+
}, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId));
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The configuration of the session that was built for a conversation, for tests to inspect.
|
|
392
|
+
*
|
|
393
|
+
* WHY THIS EXISTS. The permission boundary - an agent's tool allowlist, its prompt file, the
|
|
394
|
+
* extensions it mounts - used to be proved by reading the ARGV of a spawned child. A session is not
|
|
395
|
+
* a child and has no argv, so the evidence moved: this is what the session was actually BUILT with,
|
|
396
|
+
* which is the same fact stated in the new architecture's terms.
|
|
397
|
+
*
|
|
398
|
+
* It is a RECORD, not a control channel: nothing in the runtime reads it back, and a test that
|
|
399
|
+
* asserts on it is asserting on the real decision the real code made.
|
|
400
|
+
*/
|
|
401
|
+
builtSessions = new Map();
|
|
402
|
+
/** The configuration a conversation's session was built with, if one was built. */
|
|
403
|
+
builtSessionConfig(conversationId) {
|
|
404
|
+
return this.builtSessions.get(conversationId);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Which path a CONVERSATION on this runtime takes.
|
|
408
|
+
*
|
|
409
|
+
* Exposed so a test can assert it. The bug this exists to prevent was invisible from the outside:
|
|
410
|
+
* the version number said 0.1.95 while every conversation was still spawning a process per turn,
|
|
411
|
+
* and nothing said why. A test that can ask "would a conversation here spawn a Pi?" can catch that.
|
|
412
|
+
*/
|
|
413
|
+
get conversationPath() {
|
|
414
|
+
return this.sessionMode;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Whether the SDK-backed session layer can be used at all.
|
|
418
|
+
*
|
|
419
|
+
* The SDK is a LIBRARY and it is optional: Super Pi also supports running against a Pi installed as a
|
|
420
|
+
* COMMAND, and a server that cannot load the library still has a working one-shot path for the
|
|
421
|
+
* conversations it can serve. The answer is reported rather than thrown, so the caller can say what is
|
|
422
|
+
* missing instead of failing a turn with a stack trace.
|
|
423
|
+
*/
|
|
424
|
+
async sessionSupport() {
|
|
425
|
+
return piSdkAvailable();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Throws away every live session.
|
|
429
|
+
*
|
|
430
|
+
* Called at shutdown, and after a `revert` - the transcript on disk changed, and a session holding
|
|
431
|
+
* the old context in memory would keep answering from history the user has withdrawn.
|
|
432
|
+
*/
|
|
433
|
+
async disposeSessions(conversationId) {
|
|
434
|
+
if (conversationId)
|
|
435
|
+
await this.sessionPool.evict(conversationId);
|
|
436
|
+
else
|
|
437
|
+
await this.sessionPool.disposeAll();
|
|
438
|
+
}
|
|
439
|
+
/** Throws away the sessions that have been idle for a while. */
|
|
440
|
+
async evictIdleSessions() {
|
|
441
|
+
await this.sessionPool.evictIdle();
|
|
358
442
|
}
|
|
359
443
|
/**
|
|
360
444
|
* The agent a conversation runs as, or undefined for Pi's own defaults.
|
|
@@ -417,11 +501,18 @@ export class PiRpcRuntime {
|
|
|
417
501
|
* The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
|
|
418
502
|
* own address, so it is the same string on every turn and is read once from the constructor's options.
|
|
419
503
|
*/
|
|
504
|
+
/**
|
|
505
|
+
* The environment Super Pi's extensions read when they are loaded.
|
|
506
|
+
*
|
|
507
|
+
* IT DOES NOT INCLUDE THE CREDENTIAL. The credential is minted by the session pool, when the session
|
|
508
|
+
* is created, and added there - because the credential belongs to the SESSION (the extension reads
|
|
509
|
+
* its environment once, when it is loaded) and a token minted per turn would be replaced before the
|
|
510
|
+
* second turn of the conversation, leaving the extension holding a value the server had forgotten.
|
|
511
|
+
*/
|
|
420
512
|
async taskExtensionEnv(conversationId) {
|
|
421
513
|
const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
|
|
422
514
|
for (const problem of problems)
|
|
423
515
|
console.warn(`PiRpcRuntime: ${problem.file} is not a usable sub-agent - ${problem.message}`);
|
|
424
|
-
const capability = mintCapability(conversationId);
|
|
425
516
|
return {
|
|
426
517
|
[TASK_ENDPOINT_ENV]: this.options.endpoint ?? "",
|
|
427
518
|
[TASK_EXEC_ENDPOINT_ENV]: this.options.execEndpoint ?? "",
|
|
@@ -432,15 +523,468 @@ export class PiRpcRuntime {
|
|
|
432
523
|
[WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
|
|
433
524
|
SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
|
|
434
525
|
SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* The environment for a ONE-SHOT child, which is the same as the session's except that it carries
|
|
530
|
+
* the credential.
|
|
531
|
+
*
|
|
532
|
+
* THE DIFFERENCE IS THE LIFETIME. A one-shot child lives for one turn, so its credential is minted
|
|
533
|
+
* for that turn and dies with the child - which is the original rule, and it is still exactly right
|
|
534
|
+
* here. A session lives for many turns, so its credential is minted when the session is created (see
|
|
535
|
+
* `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
|
|
536
|
+
*/
|
|
537
|
+
async oneShotExtensionEnv(conversationId) {
|
|
538
|
+
const capability = mintCapability(conversationId);
|
|
539
|
+
return {
|
|
540
|
+
...(await this.taskExtensionEnv(conversationId)),
|
|
440
541
|
[TASK_CAPABILITY_ENV]: capability,
|
|
542
|
+
SUPER_PI_TOOLS_CAPABILITY: capability,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* RUNS A TURN ON A PERSISTENT SESSION.
|
|
547
|
+
*
|
|
548
|
+
* This is the path every conversation takes now, and it is where the latency win comes from. There is
|
|
549
|
+
* no process to spawn and none to kill: the conversation's session already exists, its context is
|
|
550
|
+
* already in memory, and a turn is a `prompt()` on it. An interrupt is `abort()` on the same session -
|
|
551
|
+
* which is why an interrupt no longer costs a process teardown and a process startup.
|
|
552
|
+
*
|
|
553
|
+
* THE EVENT ROUTING. Pi pushes events through a subscription; this method pulls them, one at a time,
|
|
554
|
+
* through a queue. The generator yields text deltas to its caller, and every non-text event is
|
|
555
|
+
* delivered to the turn's callbacks as it arrives, so the transcript shows tool calls and reasoning
|
|
556
|
+
* WHILE the model is producing them rather than after the turn ends.
|
|
557
|
+
*
|
|
558
|
+
* THE ABORT. The caller's `signal` is the turn's abort. When it fires, the session is aborted - Pi
|
|
559
|
+
* stops the current operation and waits for the session to go idle - and the turn ends. The session
|
|
560
|
+
* ITSELF survives: it is the next turn's session, and it is the reason the next turn costs no startup.
|
|
561
|
+
*/
|
|
562
|
+
/**
|
|
563
|
+
* The configuration a conversation's session is built with.
|
|
564
|
+
*
|
|
565
|
+
* SEPARATED FROM THE BUILD ON PURPOSE, because this is a decision and the build is its consequence.
|
|
566
|
+
* Everything that decides what Pi is allowed to do for a conversation - the agent's allowlist, its
|
|
567
|
+
* prompt file, the extensions it mounts, the model it runs on - is decided HERE, in one place, and
|
|
568
|
+
* the session is then built from it.
|
|
569
|
+
*
|
|
570
|
+
* Two things want that separation. The FIRST is the fingerprint: the decision is what the session is
|
|
571
|
+
* compared against, so a changed decision means the session is stale. The SECOND is the tests: the
|
|
572
|
+
* permission boundary is the most important thing this runtime does, and asserting on the decision
|
|
573
|
+
* is a DIRECT way to check it - a test that has to start a real Pi to learn what Pi was told is a
|
|
574
|
+
* test that only runs on a machine with a Pi.
|
|
575
|
+
*/
|
|
576
|
+
async sessionConfiguration(conversation, cwd) {
|
|
577
|
+
// The agent definition, read from disk EVERY turn. That freshness is the whole point of the
|
|
578
|
+
// fingerprint: an edited agent file changes the configuration, the session is replaced, and the
|
|
579
|
+
// edit takes effect on the next message with no restart and no re-selection.
|
|
580
|
+
const agent = await this.agentFor(conversation);
|
|
581
|
+
// The SDK path has no RPC `get_state` round trip before its first prompt. Preserve the old guard at
|
|
582
|
+
// the equivalent boundary: before a session is created. In particular, an unspecified conversation
|
|
583
|
+
// model means "the catalog's Pi default", which still has to pass the workspace policy.
|
|
584
|
+
if (agent?.model)
|
|
585
|
+
await this.modelValidator?.(conversation.id, { ...agent.model, label: agent.model.modelId });
|
|
586
|
+
await this.modelValidator?.(conversation.id, conversation.model);
|
|
587
|
+
// The tool selection and the prompt file are the SAME DECISION the spawn line used to carry, in the
|
|
588
|
+
// form the SDK takes. `toolSelection` is the shared translation, so an agent that declares
|
|
589
|
+
// `tools: {"*": false, "read": true}` gets exactly the same tools here as it used to get on the
|
|
590
|
+
// command line - including the empty allowlist that means "this agent talks and does not act".
|
|
591
|
+
const selection = agent
|
|
592
|
+
? toolSelection(agent.tools, { extraAllowedTools: this.extensionToolNames() })
|
|
593
|
+
: {};
|
|
594
|
+
const systemPromptFile = agent ? await writeAgentPrompt(this.agents.promptDir, agent) : undefined;
|
|
595
|
+
return {
|
|
596
|
+
...(selection.tools ? { tools: selection.tools } : {}),
|
|
597
|
+
...(selection.excludeTools ? { excludeTools: selection.excludeTools } : {}),
|
|
598
|
+
...(systemPromptFile ? { systemPromptFile } : {}),
|
|
599
|
+
...(agent?.promptMode ? { systemPromptMode: agent.promptMode } : {}),
|
|
600
|
+
extensionPaths: superPiExtensionPaths(repositoryRootFrom()),
|
|
601
|
+
extensionEnv: await this.taskExtensionEnv(conversation.id),
|
|
602
|
+
...(conversation.model ? { model: { provider: conversation.model.provider, modelId: conversation.model.modelId } } : {}),
|
|
603
|
+
...(conversation.thinkingLevel ? { thinkingLevel: conversation.thinkingLevel } : {}),
|
|
441
604
|
};
|
|
442
605
|
}
|
|
443
|
-
async *
|
|
606
|
+
async *answerOnSession(input) {
|
|
607
|
+
const { conversation, prompt, signal, cwd, images } = input;
|
|
608
|
+
const latencyStartedAt = performance.now();
|
|
609
|
+
traceLatency(conversation.id, "session.begin", latencyStartedAt);
|
|
610
|
+
const configuration = await this.sessionConfiguration(conversation, cwd);
|
|
611
|
+
// The record of what this session is built with. Written BEFORE the session is acquired, so it
|
|
612
|
+
// exists even when the build fails - a test that wants to know what Super Pi INTENDED to give Pi
|
|
613
|
+
// must be able to ask after a failure too.
|
|
614
|
+
this.builtSessions.set(conversation.id, configuration);
|
|
615
|
+
const session = await this.sessionPool.acquire({
|
|
616
|
+
conversationId: conversation.id,
|
|
617
|
+
cwd: resolve(cwd),
|
|
618
|
+
agentId: conversation.agentId ?? null,
|
|
619
|
+
extensionEnv: configuration.extensionEnv,
|
|
620
|
+
...(configuration.tools ? { tools: configuration.tools } : {}),
|
|
621
|
+
...(configuration.excludeTools ? { excludeTools: configuration.excludeTools } : {}),
|
|
622
|
+
...(configuration.systemPromptFile ? { systemPromptFile: configuration.systemPromptFile } : {}),
|
|
623
|
+
...(configuration.systemPromptMode ? { systemPromptMode: configuration.systemPromptMode } : {}),
|
|
624
|
+
...(configuration.model ? { model: configuration.model } : {}),
|
|
625
|
+
...(configuration.thinkingLevel ? { thinkingLevel: configuration.thinkingLevel } : {}),
|
|
626
|
+
});
|
|
627
|
+
traceLatency(conversation.id, "session.acquired", latencyStartedAt);
|
|
628
|
+
// THE SESSION-FILE LOCATOR. `revert` and `fork` open the conversation's transcript by PATH, and the
|
|
629
|
+
// path is a fact the session already knows: its SessionManager resolves it at creation, before any
|
|
630
|
+
// byte is written. Reported when it CHANGES rather than once per turn - the path is stable for the
|
|
631
|
+
// life of a session, and a rebuild (revert, eviction, fingerprint change) is exactly when the cached
|
|
632
|
+
// locator needs refreshing. Best-effort, like the RPC path's get_state read: a failed report must not
|
|
633
|
+
// fail the turn, it only means the next revert pays for the spawn fallback.
|
|
634
|
+
if (input.onSessionFile && typeof session.sessionFile === "string" && session.sessionFile
|
|
635
|
+
&& this.reportedSessionFiles.get(conversation.id) !== session.sessionFile) {
|
|
636
|
+
try {
|
|
637
|
+
await input.onSessionFile(session.sessionFile);
|
|
638
|
+
this.reportedSessionFiles.set(conversation.id, session.sessionFile);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
console.warn(`PiRpcRuntime: could not record the session file of conversation ${conversation.id} (${error instanceof Error ? error.message : "unknown error"}) - the next revert will locate it the slow way.`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// THE EXTENSION-UI BRIDGE, bound ONCE per session. Pi's extensions ask their blocking questions
|
|
645
|
+
// through the session's UI context, which starts life as a no-op that silently answers
|
|
646
|
+
// `undefined`/`false` - and for `confirm` a silent `false` is a fabricated "no" the user never gave.
|
|
647
|
+
// The bridge forwards to the host of whichever turn is running RIGHT NOW (registered below), and
|
|
648
|
+
// answers `cancelled` when there is none - the same rule the RPC path's no-host branch follows.
|
|
649
|
+
if (session.extensionRunner && !this.uiBoundSessions.has(session)) {
|
|
650
|
+
this.uiBoundSessions.add(session);
|
|
651
|
+
try {
|
|
652
|
+
session.extensionRunner.setUIContext(this.extensionUiBridge(conversation.id), "rpc");
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
console.warn(`PiRpcRuntime: could not bind the extension UI bridge for conversation ${conversation.id}: ${error instanceof Error ? error.message : "unknown error"}. Pi extensions that ask questions will get cancelled answers.`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (input.host)
|
|
659
|
+
this.sessionHosts.set(conversation.id, input.host);
|
|
660
|
+
// WHERE THIS TURN STARTS in Pi's own transcript, captured BEFORE the prompt. The one-shot path asked
|
|
661
|
+
// Pi over RPC (`get_entries` for the leaf, then `get_entries(since: leaf)` after the turn); a live
|
|
662
|
+
// session holds the same entries in memory, so the same two reads are synchronous. `null` is a real
|
|
663
|
+
// answer - an empty session, so the whole branch is this turn's; `undefined` means the session has no
|
|
664
|
+
// transcript to read (a test double), and nothing is recorded.
|
|
665
|
+
const transcript = session.sessionManager;
|
|
666
|
+
let turnStartLeaf;
|
|
667
|
+
if (input.onUserEntry && transcript) {
|
|
668
|
+
try {
|
|
669
|
+
turnStartLeaf = transcript.getLeafId();
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
console.warn(`PiRpcRuntime: could not read the leaf before the turn of conversation ${conversation.id} (${error instanceof Error ? error.message : "unknown error"}) - this turn's message may not become a revert boundary.`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const queue = new TurnQueue();
|
|
676
|
+
const handlers = {
|
|
677
|
+
onToolCall: input.onToolCall,
|
|
678
|
+
onToolUpdate: input.onToolUpdate,
|
|
679
|
+
onToolPreparing: input.onToolPreparing,
|
|
680
|
+
onToolEnd: input.onToolEnd,
|
|
681
|
+
onThinking: input.onThinking,
|
|
682
|
+
onThinkingEnd: input.onThinkingEnd,
|
|
683
|
+
onSteerDelivered: input.onSteerDelivered,
|
|
684
|
+
onPiQueue: input.onPiQueue,
|
|
685
|
+
};
|
|
686
|
+
const announced = new Set();
|
|
687
|
+
const report = (key) => {
|
|
688
|
+
if (announced.has(key))
|
|
689
|
+
return;
|
|
690
|
+
announced.add(key);
|
|
691
|
+
console.warn(`PiRpcRuntime: unhandled Pi session event ${key} - consumed without action.`);
|
|
692
|
+
};
|
|
693
|
+
const unsubscribe = session.subscribe((event) => {
|
|
694
|
+
try {
|
|
695
|
+
handleSessionEvent(event, queue, handlers, report);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
console.warn(`PiRpcRuntime: the session event handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
// The abort. The caller's signal is the turn's, and aborting the session is what makes the model
|
|
702
|
+
// stop. The session is NOT destroyed: it is the next turn's session.
|
|
703
|
+
//
|
|
704
|
+
// The turn has its OWN controller, and the caller's signal aborts it. A run can span several turns
|
|
705
|
+
// on one session, so "the caller wants this run to stop" and "Pi has to be told about the turn in
|
|
706
|
+
// flight" are two different facts, and only the second one is Pi's business.
|
|
707
|
+
//
|
|
708
|
+
// `abortDone` is the abort's OWN promise, kept so `stop()` can await Pi actually going idle rather
|
|
709
|
+
// than merely having been ASKED to. `AgentSession.abort()` resolves when the session is idle, which
|
|
710
|
+
// is the fact an interrupt needs before the next prompt: prompting earlier is refused by Pi with
|
|
711
|
+
// "Agent is already processing".
|
|
712
|
+
const turnController = new AbortController();
|
|
713
|
+
let abortDone = Promise.resolve();
|
|
714
|
+
const abortTurn = () => {
|
|
715
|
+
if (turnController.signal.aborted)
|
|
716
|
+
return;
|
|
717
|
+
turnController.abort();
|
|
718
|
+
abortDone = session.abort().catch((error) => {
|
|
719
|
+
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
720
|
+
});
|
|
721
|
+
};
|
|
722
|
+
const onAbort = () => { abortTurn(); };
|
|
723
|
+
if (signal.aborted)
|
|
724
|
+
abortTurn();
|
|
725
|
+
else
|
|
726
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
727
|
+
let firstTextSeen = false;
|
|
728
|
+
const finished = (async () => {
|
|
729
|
+
try {
|
|
730
|
+
await session.prompt(prompt, images?.length ? { images: images } : undefined);
|
|
731
|
+
traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt);
|
|
732
|
+
// `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
|
|
733
|
+
// been ACCEPTED and the run has been started; the agent is still streaming, still executing
|
|
734
|
+
// tools, still settling. A turn that ended here would release the reader while the model was
|
|
735
|
+
// still producing, and the NEXT turn would arrive at a session Pi considers busy - which Pi
|
|
736
|
+
// refuses with "Agent is already processing".
|
|
737
|
+
//
|
|
738
|
+
// The turn's end is the `agent_settled` EVENT, and the queue is what carries it: the bridge
|
|
739
|
+
// pushes the terminal item when Pi settles, and the consumer below reads it. This promise
|
|
740
|
+
// waits for that signal, and the race with the prompt's own failure is what keeps a turn that
|
|
741
|
+
// never settles from hanging forever - a hung turn is a conversation that never answers.
|
|
742
|
+
await Promise.race([
|
|
743
|
+
queue.settledSignal,
|
|
744
|
+
// The safety net. A session that was aborted, or a Pi that died mid-turn, may never settle,
|
|
745
|
+
// and the turn still has to end - otherwise the conversation is stuck on a turn that will
|
|
746
|
+
// never produce anything and the next message can never be sent.
|
|
747
|
+
new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
|
|
748
|
+
]);
|
|
749
|
+
traceLatency(conversation.id, "session.settled", latencyStartedAt);
|
|
750
|
+
// THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
|
|
751
|
+
// settled, so every entry this turn wrote - the user message included - is in the transcript.
|
|
752
|
+
// Recorded on abort too, on purpose: an aborted turn still PERSISTED the user message, and a
|
|
753
|
+
// message in the transcript with no recorded id is a message the user can see but never revert
|
|
754
|
+
// to. A prompt that was never accepted wrote nothing, so there is nothing to find.
|
|
755
|
+
if (transcript && turnStartLeaf !== undefined && input.onUserEntry) {
|
|
756
|
+
await this.recordSessionTurnEntryId(transcript, turnStartLeaf, conversation.id, input.onUserEntry);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
queue.fail(error instanceof Error ? error : new Error(String(error)));
|
|
761
|
+
}
|
|
762
|
+
})();
|
|
763
|
+
this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, abort: abortTurn, get abortDone() { return abortDone; } });
|
|
764
|
+
try {
|
|
765
|
+
for (;;) {
|
|
766
|
+
const item = await queue.next();
|
|
767
|
+
if (item.kind === "text") {
|
|
768
|
+
if (!firstTextSeen) {
|
|
769
|
+
firstTextSeen = true;
|
|
770
|
+
traceLatency(conversation.id, "session.first-text", latencyStartedAt);
|
|
771
|
+
}
|
|
772
|
+
yield item.delta;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (item.kind === "error")
|
|
776
|
+
throw item.error;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
signal.removeEventListener("abort", onAbort);
|
|
782
|
+
unsubscribe();
|
|
783
|
+
this.activeTurns.delete(conversation.id);
|
|
784
|
+
// The host registration belongs to THIS turn: an extension question that arrives after it ends
|
|
785
|
+
// finds no host and is answered `cancelled`, which is the honest answer to "nobody is being asked".
|
|
786
|
+
if (input.host)
|
|
787
|
+
this.sessionHosts.delete(conversation.id);
|
|
788
|
+
await finished.catch(() => undefined);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* The Extension UI context a session's extensions talk to, bridged to the host of the turn that is
|
|
793
|
+
* running right now.
|
|
794
|
+
*
|
|
795
|
+
* THE SHAPES MIRROR PI'S OWN RPC UI CONTEXT (rpc-mode.ts), method for method, because the extensions
|
|
796
|
+
* were written against that contract: `select`/`input`/`editor` resolve to the chosen text or
|
|
797
|
+
* `undefined` on cancel, `confirm` resolves to a boolean with cancel reading as `false` (Pi's own
|
|
798
|
+
* mapping, not an invention), and the one-way methods are fire-and-forget. The TUI-only methods
|
|
799
|
+
* (`setWorkingMessage`, footers, headers, `custom`, ...) are the same no-ops they are in RPC mode.
|
|
800
|
+
*
|
|
801
|
+
* A BLOCKING CALL WITH NO HOST IS CANCELLED, NEVER DEFAULTED. The no-op context Pi starts with answers
|
|
802
|
+
* `confirm` with `false`, which is indistinguishable from the user clicking "no" - the exact failure
|
|
803
|
+
* the "cancelled, never a default" rule exists to prevent. So an idle session (no turn, no host) and a
|
|
804
|
+
* failed host both answer `cancelled`, and the reason is logged.
|
|
805
|
+
*/
|
|
806
|
+
extensionUiBridge(conversationId) {
|
|
807
|
+
const ask = async (request) => {
|
|
808
|
+
const host = this.sessionHosts.get(conversationId);
|
|
809
|
+
if (!host) {
|
|
810
|
+
console.warn(`PiRpcRuntime: an extension asked ${request.method} on conversation ${conversationId} with no turn running - cancelled rather than answering on the user's behalf.`);
|
|
811
|
+
return { cancelled: true };
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
return await host.ask({ ...request, requestId: randomUUID(), blocking: true });
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
console.warn(`PiRpcRuntime: the UI host failed a ${request.method} for conversation ${conversationId} (${error instanceof Error ? error.message : "unknown error"}) - cancelled.`);
|
|
818
|
+
return { cancelled: true };
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
const notify = (request) => {
|
|
822
|
+
try {
|
|
823
|
+
this.sessionHosts.get(conversationId)?.notify({ ...request, requestId: randomUUID(), blocking: false });
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
console.warn(`PiRpcRuntime: the UI host threw on a ${request.method} notification for conversation ${conversationId}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
/** Pi passes its dialog timeout straight through; `ExtensionUiRequest.timeoutMs` is the same number. */
|
|
830
|
+
const timeoutOf = (opts) => {
|
|
831
|
+
const timeout = opts?.timeout;
|
|
832
|
+
return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0 ? { timeoutMs: timeout } : {};
|
|
833
|
+
};
|
|
834
|
+
return {
|
|
835
|
+
select: async (title, options, opts) => {
|
|
836
|
+
const answer = await ask({ method: "select", title: typeof title === "string" ? title : "", options: Array.isArray(options) ? options.filter((item) => typeof item === "string") : [], ...timeoutOf(opts) });
|
|
837
|
+
return answer.cancelled ? undefined : answer.value;
|
|
838
|
+
},
|
|
839
|
+
confirm: async (title, message, opts) => {
|
|
840
|
+
const answer = await ask({ method: "confirm", title: typeof title === "string" ? title : "", message: typeof message === "string" ? message : "", ...timeoutOf(opts) });
|
|
841
|
+
return answer.cancelled ? false : answer.confirmed === true;
|
|
842
|
+
},
|
|
843
|
+
input: async (title, placeholder, opts) => {
|
|
844
|
+
const answer = await ask({ method: "input", title: typeof title === "string" ? title : "", ...(typeof placeholder === "string" ? { placeholder } : {}), ...timeoutOf(opts) });
|
|
845
|
+
return answer.cancelled ? undefined : answer.value;
|
|
846
|
+
},
|
|
847
|
+
editor: async (title, prefill) => {
|
|
848
|
+
const answer = await ask({ method: "editor", title: typeof title === "string" ? title : "", ...(typeof prefill === "string" ? { prefill } : {}) });
|
|
849
|
+
return answer.cancelled ? undefined : answer.value;
|
|
850
|
+
},
|
|
851
|
+
notify: (message, type) => notify({ method: "notify", title: "", message: typeof message === "string" ? message : "", notifyType: (type === "warning" || type === "error" ? type : "info") }),
|
|
852
|
+
onTerminalInput: () => () => { },
|
|
853
|
+
setStatus: (key, text) => notify({ method: "setStatus", title: "", statusKey: typeof key === "string" ? key : "", ...(typeof text === "string" ? { statusText: text } : {}) }),
|
|
854
|
+
setWidget: (key, content, options) => {
|
|
855
|
+
if (content !== undefined && !Array.isArray(content))
|
|
856
|
+
return; // component factories need the TUI
|
|
857
|
+
const placement = options?.placement;
|
|
858
|
+
notify({ method: "setWidget", title: "", widgetKey: typeof key === "string" ? key : "", ...(Array.isArray(content) ? { widgetLines: content.filter((line) => typeof line === "string") } : {}), ...(placement === "aboveEditor" || placement === "belowEditor" ? { widgetPlacement: placement } : {}) });
|
|
859
|
+
},
|
|
860
|
+
setTitle: (title) => notify({ method: "setTitle", title: typeof title === "string" ? title : "" }),
|
|
861
|
+
setEditorText: (text) => notify({ method: "set_editor_text", title: "", ...(typeof text === "string" ? { text } : {}) }),
|
|
862
|
+
pasteToEditor: (text) => notify({ method: "set_editor_text", title: "", ...(typeof text === "string" ? { text } : {}) }),
|
|
863
|
+
// The rest need the TUI or have no web meaning; they are the same no-ops RPC mode provides.
|
|
864
|
+
setWorkingMessage: () => { },
|
|
865
|
+
setWorkingVisible: () => { },
|
|
866
|
+
setWorkingIndicator: () => { },
|
|
867
|
+
setHiddenThinkingLabel: () => { },
|
|
868
|
+
setFooter: () => { },
|
|
869
|
+
setHeader: () => { },
|
|
870
|
+
custom: async () => undefined,
|
|
871
|
+
getEditorText: () => "",
|
|
872
|
+
addAutocompleteProvider: () => { },
|
|
873
|
+
setEditorComponent: () => { },
|
|
874
|
+
getEditorComponent: () => undefined,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Records the id of the user message this turn wrote, read from the live session's own transcript.
|
|
879
|
+
*
|
|
880
|
+
* THE SAME CONTRACT AS `recordTurnEntryId` ON THE ONE-SHOT PATH, over memory instead of RPC: the
|
|
881
|
+
* captured pre-turn leaf is what makes the read exact, because the entries written after it belong to
|
|
882
|
+
* THIS turn, and the first USER message among them is the one Super Pi just pushed. The three outcomes
|
|
883
|
+
* are the same three:
|
|
884
|
+
*
|
|
885
|
+
* - leaf `null` - the session was empty, so the whole branch is this turn's.
|
|
886
|
+
* - leaf found in the branch - everything after it is this turn's.
|
|
887
|
+
* - leaf NOT found - the transcript moved underneath us (a compaction rewrite, a branch switch), so
|
|
888
|
+
* nothing can be attributed to this turn with certainty and nothing is recorded. Guessing here would
|
|
889
|
+
* hand back an EARLIER turn's id, and a revert to it would silently cut more than the user asked for.
|
|
890
|
+
*
|
|
891
|
+
* Every failure is a warning, never a throw: the answer has already streamed by this point, and "this
|
|
892
|
+
* message cannot be a revert boundary" is not "your message failed".
|
|
893
|
+
*/
|
|
894
|
+
async recordSessionTurnEntryId(transcript, leaf, conversationId, onUserEntry) {
|
|
895
|
+
try {
|
|
896
|
+
const branch = transcript.getBranch();
|
|
897
|
+
let startIndex = 0;
|
|
898
|
+
if (leaf !== null) {
|
|
899
|
+
const at = branch.findIndex((entry) => entry?.id === leaf);
|
|
900
|
+
if (at === -1) {
|
|
901
|
+
console.warn(`PiRpcRuntime: the entry this turn started from is no longer in the transcript of conversation ${conversationId}, so its message was not recorded and cannot be used as a revert boundary.`);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
startIndex = at + 1;
|
|
905
|
+
}
|
|
906
|
+
const firstUser = branch.slice(startIndex).find((entry) => {
|
|
907
|
+
const candidate = entry;
|
|
908
|
+
return candidate?.type === "message" && candidate.message?.role === "user" && typeof candidate.id === "string";
|
|
909
|
+
});
|
|
910
|
+
if (!firstUser) {
|
|
911
|
+
console.warn(`PiRpcRuntime: the entries this turn of conversation ${conversationId} wrote contain no user message, so nothing was recorded as a revert boundary.`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
await onUserEntry(firstUser.id);
|
|
915
|
+
}
|
|
916
|
+
catch (error) {
|
|
917
|
+
console.warn(`PiRpcRuntime: could not record this turn's Pi entry id for conversation ${conversationId} (${error instanceof Error ? error.message : "unknown error"}) - its message cannot be used as a revert boundary.`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* The tool names Super Pi's own extensions contribute, for the agent's allowlist.
|
|
922
|
+
*
|
|
923
|
+
* The same list the spawn line used to carry, so an agent that declares `tools: {"*": false, "read":
|
|
924
|
+
* true}` gets exactly the same tools it used to get - and an agent that says `task: false` still wins,
|
|
925
|
+
* because `toolFlags` treats an explicit false as outranking a host extra.
|
|
926
|
+
*/
|
|
927
|
+
extensionToolNames() {
|
|
928
|
+
const names = [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"];
|
|
929
|
+
if (this.memory)
|
|
930
|
+
names.push("memory", "skill_manage", "session_search");
|
|
931
|
+
if (this.knowledge)
|
|
932
|
+
names.push(KNOWLEDGE_TOOL_NAME);
|
|
933
|
+
return names;
|
|
934
|
+
}
|
|
935
|
+
async *answer(input) {
|
|
936
|
+
// The one-shot paths keep their own child process, and they are NOT the same thing as a conversation
|
|
937
|
+
// turn: a memory review and a knowledge reranker have their own ids, their own tool sets and their own
|
|
938
|
+
// lifetimes, and folding them into a conversation's session would give them that conversation's
|
|
939
|
+
// context. They are identified by the flags that describe exactly that.
|
|
940
|
+
if (input.memoryReview || input.knowledgeRerank) {
|
|
941
|
+
yield* this.answerOneShot(input);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
// WHICH PATH A CONVERSATION RUNS ON, and why it is NOT decided by which Pi was named.
|
|
945
|
+
//
|
|
946
|
+
// The session path does not use a Pi COMMAND at all: it imports Pi as a library, and the library it
|
|
947
|
+
// imports is the one installed next to Super Pi. A named command is a PROGRAM, and the session path
|
|
948
|
+
// cannot run a program as itself.
|
|
949
|
+
//
|
|
950
|
+
// So the two are different questions, and they are asked of different variables:
|
|
951
|
+
//
|
|
952
|
+
// - `SUPER_PI_HOST_PI` - the Pi that is hosting Super Pi, set by the Pi extension. It steers the
|
|
953
|
+
// ONE-SHOT work (the sub-agents spawn a Pi) and nothing else. It does NOT redirect conversations.
|
|
954
|
+
// - `SUPER_PI_SESSION_MODE=oneshot` - the explicit request to run conversations by spawning a Pi
|
|
955
|
+
// instead of by using the SDK. It is slower, and it is here for the test suite, which drives a fake
|
|
956
|
+
// Pi that speaks the RPC protocol - a program, not a library.
|
|
957
|
+
//
|
|
958
|
+
// THE BUG THIS FIXES. The extension used to set `SUPER_PI_PI_COMMAND`, and an explicitly named Pi
|
|
959
|
+
// used to mean the one-shot path - so every conversation started from inside Pi spawned a process for
|
|
960
|
+
// every turn. That is the exact architecture this refactor replaced, and it was still running for
|
|
961
|
+
// anyone who started Super Pi the way most people do. The version number said 0.1.95; the speed said
|
|
962
|
+
// 0.1.84.
|
|
963
|
+
if (this.sessionMode === "oneshot") {
|
|
964
|
+
yield* this.answerOneShot(input);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
// The main path: a persistent session, reused across turns. When the SDK cannot be loaded the turn
|
|
968
|
+
// falls back to the one-shot path rather than failing, and the reason is reported once - a server
|
|
969
|
+
// that cannot use the SDK still serves conversations, just at the old speed.
|
|
970
|
+
const support = await this.sessionSupport();
|
|
971
|
+
if (!support.available) {
|
|
972
|
+
if (!this.warnedMissingSdk) {
|
|
973
|
+
this.warnedMissingSdk = true;
|
|
974
|
+
console.warn(`PiRpcRuntime: ${support.reason} Falling back to one-shot Pi processes for conversations.`);
|
|
975
|
+
}
|
|
976
|
+
yield* this.answerOneShot(input);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
yield* this.answerOnSession(input);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* The one-shot path: a fresh child process for a single turn.
|
|
983
|
+
*
|
|
984
|
+
* Still used for the memory review, the knowledge reranker, and as the fallback when Pi's SDK is not
|
|
985
|
+
* installed. The main conversation path does NOT come here - see `answer`.
|
|
986
|
+
*/
|
|
987
|
+
async *answerOneShot({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }) {
|
|
444
988
|
const latencyStartedAt = performance.now();
|
|
445
989
|
traceLatency(conversation.id, "answer.begin", latencyStartedAt);
|
|
446
990
|
await mkdir(this.sessionDir, { recursive: true });
|
|
@@ -515,7 +1059,7 @@ export class PiRpcRuntime {
|
|
|
515
1059
|
// every turn would silently replace the child's environment with whatever THIS process has at
|
|
516
1060
|
// spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
|
|
517
1061
|
// but the four documented additions.
|
|
518
|
-
...((extensionArgs.length || memoryEnabled || knowledgeEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.
|
|
1062
|
+
...((extensionArgs.length || memoryEnabled || knowledgeEnabled) ? { env: { ...process.env, ...(extensionArgs.length ? await this.oneShotExtensionEnv(conversation.id) : {}), ...(memoryEnabled ? {
|
|
519
1063
|
SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
|
|
520
1064
|
SUPER_PI_MEMORY_TOKEN: memoryToken,
|
|
521
1065
|
SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
|
|
@@ -928,9 +1472,10 @@ export class PiRpcRuntime {
|
|
|
928
1472
|
finally {
|
|
929
1473
|
signal.removeEventListener("abort", abort);
|
|
930
1474
|
this.runs.delete(conversation.id);
|
|
931
|
-
// The capability dies with the
|
|
1475
|
+
// The capability dies with the CHILD that was given it. In this `finally`, so it happens on every
|
|
932
1476
|
// exit - a turn that answered, a turn that threw, and a turn whose child was killed - because a
|
|
933
|
-
// credential whose
|
|
1477
|
+
// credential whose process has ended is a credential with no owner. The one-shot path is the only
|
|
1478
|
+
// place this still holds literally: on the session path the child does not die with the turn.
|
|
934
1479
|
releaseCapability(conversation.id);
|
|
935
1480
|
if (memoryToken)
|
|
936
1481
|
this.memory?.release(memoryToken);
|
|
@@ -1056,7 +1601,7 @@ export class PiRpcRuntime {
|
|
|
1056
1601
|
* elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
|
|
1057
1602
|
*/
|
|
1058
1603
|
async revert({ conversation, cwd, boundaryEntryId, beforeBoundary }) {
|
|
1059
|
-
if (this.runs.has(conversation.id))
|
|
1604
|
+
if (this.runs.has(conversation.id) || this.activeTurns.has(conversation.id))
|
|
1060
1605
|
throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
|
|
1061
1606
|
if (!this.pi.found)
|
|
1062
1607
|
throw new Error(this.pi.reason);
|
|
@@ -1069,6 +1614,14 @@ export class PiRpcRuntime {
|
|
|
1069
1614
|
const exists = await stat(cached).catch(() => undefined);
|
|
1070
1615
|
if (exists?.isFile()) {
|
|
1071
1616
|
const result = await truncateSessionFile(cached, boundaryEntryId, conversation.id, beforeBoundary);
|
|
1617
|
+
// THE SESSION MUST GO. The truncation above changed the transcript ON DISK, and a live session
|
|
1618
|
+
// holds the OLD context in memory. Nothing in Pi would notice the difference: the model would
|
|
1619
|
+
// keep answering from history the user has just withdrawn, while `state.json` and the file on
|
|
1620
|
+
// disk both said it was gone. Three views of the conversation, two of them wrong, and no error
|
|
1621
|
+
// anywhere - so the session is destroyed here and the next turn rebuilds it from the truncated
|
|
1622
|
+
// file. This is the one place where eviction is a correctness requirement and not an
|
|
1623
|
+
// optimization.
|
|
1624
|
+
await this.sessionPool.evict(conversation.id);
|
|
1072
1625
|
return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
1073
1626
|
}
|
|
1074
1627
|
}
|
|
@@ -1285,6 +1838,21 @@ export class PiRpcRuntime {
|
|
|
1285
1838
|
reply(buildExtensionUiResponse(request, answer));
|
|
1286
1839
|
}
|
|
1287
1840
|
async stop(conversationId) {
|
|
1841
|
+
// A turn on a persistent session is stopped by aborting THAT SESSION, and the session survives - it
|
|
1842
|
+
// is the next turn's session. This is the whole reason an interrupt no longer costs a process
|
|
1843
|
+
// teardown and a process startup: there is no process to tear down.
|
|
1844
|
+
const turn = this.activeTurns.get(conversationId);
|
|
1845
|
+
if (turn) {
|
|
1846
|
+
turn.abort();
|
|
1847
|
+
// WAIT FOR PI TO ACTUALLY GO IDLE, not merely to have been asked. `AgentSession.abort()` resolves
|
|
1848
|
+
// when the session is idle, and idle is the fact the caller needs: an interrupt that starts the
|
|
1849
|
+
// next turn before this one has unwound is refused by Pi with "Agent is already processing". The
|
|
1850
|
+
// backstop keeps a wedged session from hanging a stop forever - the turn's own 5s safety net then
|
|
1851
|
+
// still ends it.
|
|
1852
|
+
await Promise.race([turn.abortDone, new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000))]);
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
// The one-shot path: a child process that has to be told to abort over its stdin.
|
|
1288
1856
|
const run = this.runs.get(conversationId);
|
|
1289
1857
|
if (!run)
|
|
1290
1858
|
return;
|
|
@@ -1441,7 +2009,43 @@ export class PiRpcRuntime {
|
|
|
1441
2009
|
await killTree(child.pid);
|
|
1442
2010
|
}
|
|
1443
2011
|
}
|
|
2012
|
+
/**
|
|
2013
|
+
* The live session for a conversation, if there is one.
|
|
2014
|
+
*
|
|
2015
|
+
* Throws when there is none, because every caller wants a session it can act on - a steer that has
|
|
2016
|
+
* nowhere to go is a refusal the caller turns into "this message could not be delivered", not a
|
|
2017
|
+
* silent success.
|
|
2018
|
+
*/
|
|
2019
|
+
async sessionFor(conversationId) {
|
|
2020
|
+
const session = this.sessionPool.get(conversationId);
|
|
2021
|
+
if (!session)
|
|
2022
|
+
throw new SteerUnavailableError(`会话 ${conversationId} 还没有可用的 Pi 会话`);
|
|
2023
|
+
return session;
|
|
2024
|
+
}
|
|
1444
2025
|
async steer(conversationId, message, images) {
|
|
2026
|
+
// THE SESSION PATH. A steer on a persistent session is a `prompt` with `streamingBehavior:
|
|
2027
|
+
// "steer"` - the same queueing semantics Pi's RPC `steer` command has, expressed the way the SDK
|
|
2028
|
+
// takes it. The session is NOT aborted and NOT given a second writer: the message joins the turn
|
|
2029
|
+
// that is already running, and Pi delivers it at the next turn boundary.
|
|
2030
|
+
//
|
|
2031
|
+
// THE DISPOSITION. The SDK does not answer a steer with a disposition the way the RPC command does,
|
|
2032
|
+
// so it is derived from what the SDK can tell us: a steer accepted while a turn is running is
|
|
2033
|
+
// `queued` (Pi will deliver it at the next boundary), and one accepted with nothing running is
|
|
2034
|
+
// `handled`. The distinction is the one the caller needs: a queued message is still cancellable
|
|
2035
|
+
// and has NOT yet reached the model.
|
|
2036
|
+
if (this.activeTurns.has(conversationId)) {
|
|
2037
|
+
const session = await this.sessionFor(conversationId);
|
|
2038
|
+
const imagePayloads = this.options.uploadsDir && images?.length
|
|
2039
|
+
? await loadConversationImages(this.options.uploadsDir, conversationId, images)
|
|
2040
|
+
: undefined;
|
|
2041
|
+
try {
|
|
2042
|
+
await session.prompt(message, { ...(imagePayloads ? { images: imagePayloads } : {}), streamingBehavior: "steer" });
|
|
2043
|
+
}
|
|
2044
|
+
catch (error) {
|
|
2045
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2046
|
+
}
|
|
2047
|
+
return "queued";
|
|
2048
|
+
}
|
|
1445
2049
|
const run = this.runs.get(conversationId);
|
|
1446
2050
|
if (!run || !this.canSteer(conversationId))
|
|
1447
2051
|
throw new SteerUnavailableError(`会话 ${conversationId} 的 Pi 进程已结束`);
|