@evandu/super-pi 0.1.138 → 0.1.140
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-knowledge/index.ts +11 -13
- package/agents/super-pi-memory/index.ts +11 -5
- package/agents/super-pi-task/core.ts +418 -1
- package/agents/super-pi-task/index.ts +115 -0
- package/bin/super-pi.js +7 -11
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -6
- package/dist/client/assets/{CodeMirrorEditor-CVImD0nh.js → CodeMirrorEditor-DvZeOLny.js} +2 -2
- package/dist/client/assets/{dist-CiEW7LQW.js → dist-3BuyVG3H.js} +1 -1
- package/dist/client/assets/{dist-faxwvvOU.js → dist-BCUfH4nP.js} +1 -1
- package/dist/client/assets/{dist-yONALz0V.js → dist-BIIMtU9W.js} +1 -1
- package/dist/client/assets/{dist-CKI1xVeE.js → dist-C-QmRwbd.js} +1 -1
- package/dist/client/assets/{dist-4uPxkRdw.js → dist-CTkzLBdb.js} +1 -1
- package/dist/client/assets/{dist-CUACXe1Y.js → dist-C_VuSpx8.js} +1 -1
- package/dist/client/assets/{dist-BwQM8_Mk.js → dist-CaUsvSz1.js} +1 -1
- package/dist/client/assets/{dist-Clf26yld.js → dist-Cb1gxS57.js} +1 -1
- package/dist/client/assets/{dist-YB4GJD3u.js → dist-CqDSeoUk.js} +1 -1
- package/dist/client/assets/{dist-DozjvPAZ.js → dist-D7yfnKNC.js} +1 -1
- package/dist/client/assets/{dist-Cocw5XJ_.js → dist-DA8yW4lK.js} +1 -1
- package/dist/client/assets/{dist-amySbADh.js → dist-DAcS4Kj8.js} +1 -1
- package/dist/client/assets/{dist-B75OT6A9.js → dist-DN0M4DAX.js} +1 -1
- package/dist/client/assets/{dist-At94VK1P.js → dist-DRgWZqFH.js} +1 -1
- package/dist/client/assets/{dist-BIgRwYsI.js → dist-DdfW8Gjv.js} +1 -1
- package/dist/client/assets/{dist-DhEa5hHR.js → dist-DlQ8PMvv.js} +1 -1
- package/dist/client/assets/{dist-quQ6u6Rw.js → dist-Dvca0LeR.js} +1 -1
- package/dist/client/assets/{dist-BqblSnWL.js → dist-HJdP1yxN.js} +1 -1
- package/dist/client/assets/{dist-pnGHl3nq.js → dist-gBAr9yXF.js} +1 -1
- package/dist/client/assets/{dist-1H_gIENV.js → dist-gyzurUDR.js} +1 -1
- package/dist/client/assets/{dist-DSCjkO-y.js → dist-qG53hT1b.js} +1 -1
- package/dist/client/assets/{dist-CNP-Q5Gs.js → dist-r4Kq1ABA.js} +1 -1
- package/dist/client/assets/{index-CNoiRdJC.js → index-CNbh1fL2.js} +14 -14
- package/dist/client/assets/index-DCSFD4VV.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +13 -4
- package/dist/server/application/conversation-service.js +112 -61
- package/dist/server/application/run-service.d.ts +13 -0
- package/dist/server/application/run-service.js +255 -69
- package/dist/server/application/scheduled-task-service.js +8 -1
- package/dist/server/application/session-orchestrator-service.d.ts +113 -0
- package/dist/server/application/session-orchestrator-service.js +234 -0
- package/dist/server/application/workspace-service.js +16 -1
- package/dist/server/feishu-cli.js +15 -4
- package/dist/server/file-index.d.ts +30 -0
- package/dist/server/file-index.js +49 -4
- package/dist/server/files.d.ts +21 -0
- package/dist/server/files.js +38 -0
- package/dist/server/http-server.js +1580 -1402
- package/dist/server/index.js +23 -6
- package/dist/server/logging.js +56 -3
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +1 -0
- package/dist/server/pi-session.d.ts +118 -0
- package/dist/server/pi-session.js +132 -10
- package/dist/server/runtime.d.ts +48 -0
- package/dist/server/runtime.js +189 -86
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/session-ledger.d.ts +60 -0
- package/dist/server/session-ledger.js +76 -0
- package/dist/server/store.d.ts +44 -1
- package/dist/server/store.js +114 -12
- package/dist/server/subagent-runs.d.ts +31 -1
- package/dist/server/subagent-runs.js +48 -20
- package/dist/server/subagents.js +7 -0
- package/dist/server/task-tool.d.ts +18 -0
- package/dist/server/task-tool.js +85 -0
- package/dist/server/tool-runner.js +13 -4
- package/dist/server/types.d.ts +50 -0
- package/dist/server/ws-server.d.ts +86 -6
- package/dist/server/ws-server.js +291 -51
- package/package.json +16 -2
- package/scripts/diagnose.mjs +161 -58
- package/dist/client/assets/index-FxTrUapG.css +0 -1
package/dist/server/runtime.js
CHANGED
|
@@ -21,7 +21,7 @@ import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, Turn
|
|
|
21
21
|
// paths recognise a delivery identically. Re-exported because they were part of this module's surface.
|
|
22
22
|
export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
|
|
23
23
|
import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
|
|
24
|
-
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, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, rosterPayload, } from "./task-tool.js";
|
|
24
|
+
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, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, rosterPayload, } from "./task-tool.js";
|
|
25
25
|
import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
|
|
26
26
|
import { loadConversationImages } from "./upload.js";
|
|
27
27
|
import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
|
|
@@ -411,6 +411,20 @@ export class PiRpcRuntime {
|
|
|
411
411
|
* it was started.
|
|
412
412
|
*/
|
|
413
413
|
pi;
|
|
414
|
+
/**
|
|
415
|
+
* HOW MANY LIVE SESSIONS THIS CONVERSATION HAS, and whether it has one at all.
|
|
416
|
+
*
|
|
417
|
+
* A reader of this number learns whether a conversation's context is still in Pi's memory. That is
|
|
418
|
+
* the fact a revert has to change: it rewrites the transcript on disk, and a live session keeps the
|
|
419
|
+
* OLD context in memory, so a session that outlived the revert is a model that answers from
|
|
420
|
+
* history the person withdrew - with nothing saying so.
|
|
421
|
+
*
|
|
422
|
+
* It is here because that failure is invisible from the outside: the file is shorter, the state is
|
|
423
|
+
* clean, and only the model knows something the reader cannot see.
|
|
424
|
+
*/
|
|
425
|
+
liveSessionCount(conversationId) {
|
|
426
|
+
return this.sessionPool.has(conversationId) ? 1 : 0;
|
|
427
|
+
}
|
|
414
428
|
canSteer(conversationId) {
|
|
415
429
|
// A steer is only meaningful while a turn is RUNNING. A live session with nothing running is not a
|
|
416
430
|
// steer target: there is no current answer for the message to be inserted into, and the transcript's
|
|
@@ -695,6 +709,8 @@ export class PiRpcRuntime {
|
|
|
695
709
|
[TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
|
|
696
710
|
[SCHEDULED_TASK_ENDPOINT_ENV]: this.options.scheduledTaskEndpoint ?? "",
|
|
697
711
|
[WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
|
|
712
|
+
[ORCHESTRATOR_ENDPOINT_ENV]: this.options.orchestratorEndpoint ?? "",
|
|
713
|
+
SUPER_PI_ORCHESTRATOR_CONVERSATION: conversationId,
|
|
698
714
|
SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
|
|
699
715
|
SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
|
|
700
716
|
// The document tools' endpoint and the conversation they act for. The CREDENTIAL is not here:
|
|
@@ -801,6 +817,7 @@ export class PiRpcRuntime {
|
|
|
801
817
|
const session = await this.sessionPool.acquire({
|
|
802
818
|
conversationId: conversation.id,
|
|
803
819
|
cwd: resolve(cwd),
|
|
820
|
+
...(conversation.piSessionFile ? { sessionFile: conversation.piSessionFile } : {}),
|
|
804
821
|
agentId: conversation.agentId ?? null,
|
|
805
822
|
extensionEnv: configuration.extensionEnv,
|
|
806
823
|
...(configuration.tools ? { tools: configuration.tools } : {}),
|
|
@@ -898,20 +915,18 @@ export class PiRpcRuntime {
|
|
|
898
915
|
// The turn has its OWN controller, and the caller's signal aborts it. A run can span several turns
|
|
899
916
|
// on one session, so "the caller wants this run to stop" and "Pi has to be told about the turn in
|
|
900
917
|
// flight" are two different facts, and only the second one is Pi's business.
|
|
901
|
-
//
|
|
902
|
-
// `abortDone` is the abort's OWN promise, kept so `stop()` can await Pi actually going idle rather
|
|
903
|
-
// than merely having been ASKED to. `AgentSession.abort()` resolves when the session is idle, which
|
|
904
|
-
// is the fact an interrupt needs before the next prompt: prompting earlier is refused by Pi with
|
|
905
|
-
// "Agent is already processing".
|
|
906
918
|
const turnController = new AbortController();
|
|
907
919
|
let abortDone = Promise.resolve();
|
|
920
|
+
const abortSession = () => {
|
|
921
|
+
abortDone = session.abort().catch((error) => {
|
|
922
|
+
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
923
|
+
});
|
|
924
|
+
};
|
|
908
925
|
const abortTurn = () => {
|
|
909
926
|
if (turnController.signal.aborted)
|
|
910
927
|
return;
|
|
911
928
|
turnController.abort();
|
|
912
|
-
|
|
913
|
-
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
914
|
-
});
|
|
929
|
+
abortSession();
|
|
915
930
|
};
|
|
916
931
|
const onAbort = () => { abortTurn(); };
|
|
917
932
|
if (signal.aborted)
|
|
@@ -936,11 +951,29 @@ export class PiRpcRuntime {
|
|
|
936
951
|
}
|
|
937
952
|
};
|
|
938
953
|
let firstTextSeen = false;
|
|
954
|
+
let markPromptFinished;
|
|
955
|
+
const promptFinished = new Promise((resolvePromise) => { markPromptFinished = resolvePromise; });
|
|
939
956
|
const finished = (async () => {
|
|
940
957
|
try {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
958
|
+
let promptAccepted = false;
|
|
959
|
+
const promptOptions = {
|
|
960
|
+
...(images?.length ? { images: images } : {}),
|
|
961
|
+
preflightResult: (success) => {
|
|
962
|
+
if (!success)
|
|
963
|
+
return;
|
|
964
|
+
promptAccepted = true;
|
|
965
|
+
traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize());
|
|
966
|
+
// Pi calls this hook immediately BEFORE _runAgentPrompt synchronously marks the run
|
|
967
|
+
// active. An abort during before_agent_start can have returned while Pi was idle. Repeat
|
|
968
|
+
// it in the next microtask, after the active flag and the agent's controller exist. Waiting
|
|
969
|
+
// for prompt() would wait for the entire answer and make the cancellation ineffective.
|
|
970
|
+
if (turnController.signal.aborted)
|
|
971
|
+
queueMicrotask(() => {
|
|
972
|
+
if (!queue.isSettled)
|
|
973
|
+
abortSession();
|
|
974
|
+
});
|
|
975
|
+
},
|
|
976
|
+
};
|
|
944
977
|
// A STOP THAT ARRIVED WHILE THIS SESSION WAS BEING BUILT MUST NOT SEND THE PROMPT.
|
|
945
978
|
//
|
|
946
979
|
// The window between "the person pressed stop" and "the prompt goes out" is the whole session
|
|
@@ -962,38 +995,15 @@ export class PiRpcRuntime {
|
|
|
962
995
|
return;
|
|
963
996
|
}
|
|
964
997
|
await session.prompt(prompt, promptOptions);
|
|
965
|
-
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
// second one is the one that lands.
|
|
975
|
-
if (signal.aborted) {
|
|
976
|
-
// The same 5s backstop `runtime.stop` uses: a wedged session must not hang the turn forever.
|
|
977
|
-
await Promise.race([
|
|
978
|
-
session.abort().catch((error) => {
|
|
979
|
-
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} after a late cancellation threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
980
|
-
}),
|
|
981
|
-
new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
|
|
982
|
-
]);
|
|
983
|
-
}
|
|
984
|
-
// `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
|
|
985
|
-
// been ACCEPTED and the run has been started; the agent is still streaming, still executing
|
|
986
|
-
// tools, still settling. A turn that ended here would release the reader while the model was
|
|
987
|
-
// still producing, and the NEXT turn would arrive at a session Pi considers busy - which Pi
|
|
988
|
-
// refuses with "Agent is already processing".
|
|
989
|
-
//
|
|
990
|
-
// The turn's end is the `agent_settled` EVENT, and the queue is what carries it: the bridge
|
|
991
|
-
// pushes the terminal item when Pi settles, and the consumer below reads it. This promise
|
|
992
|
-
// waits for that signal, and the race with the prompt's own failure is what keeps a turn that
|
|
993
|
-
// never settles from hanging forever - a hung turn is a conversation that never answers.
|
|
994
|
-
// `prompt()` only means Pi accepted the request. Do not end this generator on a wall-clock
|
|
995
|
-
// timeout: the session may still be streaming or executing a tool, and ending here drops
|
|
996
|
-
// subsequent events while falsely allowing the next prompt onto a busy session.
|
|
998
|
+
markPromptFinished();
|
|
999
|
+
// Pi 0.86's prompt() awaits the whole agent run. Input hooks and extension commands can also
|
|
1000
|
+
// handle it without starting a run, in which case no agent_settled event is emitted. Pi's idle
|
|
1001
|
+
// flag distinguishes that legitimate completion from an asynchronous session adapter whose
|
|
1002
|
+
// prompt promise returns before its stream finishes.
|
|
1003
|
+
if (promptAccepted && session.isIdle === true && !queue.isSettled)
|
|
1004
|
+
queue.push({ kind: "end" });
|
|
1005
|
+
// Drain every queued callback and delta before releasing this turn, even if Pi already went
|
|
1006
|
+
// idle. An adapter that returns early must still provide the authoritative settled event.
|
|
997
1007
|
await queue.settledSignal;
|
|
998
1008
|
traceLatency(conversation.id, "session.settled", latencyStartedAt);
|
|
999
1009
|
// THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
|
|
@@ -1008,9 +1018,22 @@ export class PiRpcRuntime {
|
|
|
1008
1018
|
catch (error) {
|
|
1009
1019
|
queue.fail(error instanceof Error ? error : new Error(String(error)));
|
|
1010
1020
|
}
|
|
1021
|
+
finally {
|
|
1022
|
+
// Also release preparation failures and a turn cancelled before prompt() was called.
|
|
1023
|
+
markPromptFinished();
|
|
1024
|
+
}
|
|
1011
1025
|
})();
|
|
1012
|
-
this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, abort: abortTurn, get abortDone() { return abortDone; } });
|
|
1026
|
+
this.activeTurns.set(conversation.id, { queue, unsubscribe, finished, promptFinished, abort: abortTurn, get abortDone() { return abortDone; } });
|
|
1013
1027
|
try {
|
|
1028
|
+
// THE SPLITTER LIVES HERE, INSIDE THE LOOP THAT DRAINS THE QUEUE, and its thinking events are
|
|
1029
|
+
// applied IN LINE rather than deferred. Two producers write into this one queue - Pi's session
|
|
1030
|
+
// events (text deltas, tool calls) and the splitter's reading of the text - and the deferred way
|
|
1031
|
+
// was to announce the second producer's findings at the END of the queue, which put "the thinking
|
|
1032
|
+
// ended" BEHIND the tool call that Pi had announced after the very text that ended it. The tool
|
|
1033
|
+
// callback then ran while the turn still thought it was thinking, and the call was filed inside
|
|
1034
|
+
// the thinking block (UX-008). In line, the thinking lifecycle happens where its text sits, so
|
|
1035
|
+
// the order Pi announced is the order the turn sees.
|
|
1036
|
+
const splitter = new InlineThinkingSplitter();
|
|
1014
1037
|
for (;;) {
|
|
1015
1038
|
const item = await queue.next();
|
|
1016
1039
|
if (item.kind === "text") {
|
|
@@ -1018,7 +1041,17 @@ export class PiRpcRuntime {
|
|
|
1018
1041
|
firstTextSeen = true;
|
|
1019
1042
|
traceLatency(conversation.id, "session.first-text", latencyStartedAt);
|
|
1020
1043
|
}
|
|
1021
|
-
|
|
1044
|
+
for (const segment of splitter.push(item.delta)) {
|
|
1045
|
+
if (segment.kind === "text") {
|
|
1046
|
+
yield segment.text;
|
|
1047
|
+
}
|
|
1048
|
+
else if (segment.kind === "thinking") {
|
|
1049
|
+
await input.onThinking?.(segment.text);
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
input.onThinkingEnd?.();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1022
1055
|
continue;
|
|
1023
1056
|
}
|
|
1024
1057
|
if (item.kind === "callback") {
|
|
@@ -1027,6 +1060,19 @@ export class PiRpcRuntime {
|
|
|
1027
1060
|
}
|
|
1028
1061
|
if (item.kind === "error")
|
|
1029
1062
|
throw item.error;
|
|
1063
|
+
// THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed
|
|
1064
|
+
// region behind, and it belongs in the block rather than in the answer.
|
|
1065
|
+
for (const segment of splitter.flush()) {
|
|
1066
|
+
if (segment.kind === "text") {
|
|
1067
|
+
yield segment.text;
|
|
1068
|
+
}
|
|
1069
|
+
else if (segment.kind === "thinking") {
|
|
1070
|
+
await input.onThinking?.(segment.text);
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
input.onThinkingEnd?.();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1030
1076
|
break;
|
|
1031
1077
|
}
|
|
1032
1078
|
}
|
|
@@ -1196,6 +1242,8 @@ export class PiRpcRuntime {
|
|
|
1196
1242
|
// loaded-but-undeclared tool - broken with no error to explain it.
|
|
1197
1243
|
if (process.env.SUPER_PI_FEISHU_ENDPOINT)
|
|
1198
1244
|
names.push(FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME);
|
|
1245
|
+
if (this.options.orchestratorEndpoint)
|
|
1246
|
+
names.push(SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME);
|
|
1199
1247
|
return names;
|
|
1200
1248
|
}
|
|
1201
1249
|
/**
|
|
@@ -1220,27 +1268,20 @@ export class PiRpcRuntime {
|
|
|
1220
1268
|
* transcript all work without knowing where the reasoning came from.
|
|
1221
1269
|
*/
|
|
1222
1270
|
async *answer(input) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
if (segment.kind === "text")
|
|
1238
|
-
yield segment.text;
|
|
1239
|
-
else if (segment.kind === "thinking")
|
|
1240
|
-
input.onThinking?.(segment.text);
|
|
1241
|
-
else
|
|
1242
|
-
input.onThinkingEnd?.();
|
|
1243
|
-
}
|
|
1271
|
+
// THE SPLITTING HAPPENS INSIDE `answerTurn`, NOT HERE - and the reason is ORDER.
|
|
1272
|
+
//
|
|
1273
|
+
// The reasoning of an inline-thinking model is a `<think>…</think>` region INSIDE the answer text,
|
|
1274
|
+
// so "the thinking ended" is a fact that only exists once the text carrying the closing tag has
|
|
1275
|
+
// been read. When that fact was produced out here - one layer ABOVE the queue that carries Pi's
|
|
1276
|
+
// events - it was announced by DEFERRED, and a deferred announcement lands at the END of the
|
|
1277
|
+
// queue: BEHIND the tool call that Pi had already announced. So the tool callback ran while the
|
|
1278
|
+
// turn still believed it was thinking, and the call was filed INSIDE the thinking block - a
|
|
1279
|
+
// question that came after the answer was drawn inside the fold above it (UX-008).
|
|
1280
|
+
//
|
|
1281
|
+
// Splitting inside the queue's consumer keeps the two producers in ONE order: the thinking
|
|
1282
|
+
// lifecycle derived from a piece of text happens where that text sits, and a tool call Pi
|
|
1283
|
+
// announced after that text is announced after it too.
|
|
1284
|
+
yield* this.answerTurn(input);
|
|
1244
1285
|
}
|
|
1245
1286
|
/**
|
|
1246
1287
|
* THE TURN ITSELF, on whichever path it runs. See `answer`, which is the seam the reasoning is
|
|
@@ -1252,7 +1293,7 @@ export class PiRpcRuntime {
|
|
|
1252
1293
|
// lifetimes, and folding them into a conversation's session would give them that conversation's
|
|
1253
1294
|
// context. They are identified by the flags that describe exactly that.
|
|
1254
1295
|
if (input.memoryReview || input.knowledgeRerank) {
|
|
1255
|
-
yield* this.
|
|
1296
|
+
yield* this.answerOneShotWithInlineThinking(input);
|
|
1256
1297
|
return;
|
|
1257
1298
|
}
|
|
1258
1299
|
// WHICH PATH A CONVERSATION RUNS ON, and why it is NOT decided by which Pi was named.
|
|
@@ -1275,7 +1316,7 @@ export class PiRpcRuntime {
|
|
|
1275
1316
|
// anyone who started Super Pi the way most people do. The version number said 0.1.95; the speed said
|
|
1276
1317
|
// 0.1.84.
|
|
1277
1318
|
if (this.sessionMode === "oneshot") {
|
|
1278
|
-
yield* this.
|
|
1319
|
+
yield* this.answerOneShotWithInlineThinking(input);
|
|
1279
1320
|
return;
|
|
1280
1321
|
}
|
|
1281
1322
|
// The main path: a persistent session, reused across turns. When the SDK cannot be loaded the turn
|
|
@@ -1287,11 +1328,53 @@ export class PiRpcRuntime {
|
|
|
1287
1328
|
this.warnedMissingSdk = true;
|
|
1288
1329
|
console.warn(`PiRpcRuntime: ${support.reason} Falling back to one-shot Pi processes for conversations.`);
|
|
1289
1330
|
}
|
|
1290
|
-
yield* this.
|
|
1331
|
+
yield* this.answerOneShotWithInlineThinking(input);
|
|
1291
1332
|
return;
|
|
1292
1333
|
}
|
|
1293
1334
|
yield* this.answerOnSession(input);
|
|
1294
1335
|
}
|
|
1336
|
+
/**
|
|
1337
|
+
* THE ONE-SHOT PATH, WITH THE INLINE THINKING SPLIT OUT OF IT.
|
|
1338
|
+
*
|
|
1339
|
+
* WHY THIS IS A SEPARATE FUNCTION AND NOT A LINE IN `answerTurn`. The reasoning of an inline-thinking
|
|
1340
|
+
* model is a `<think>…</think>` region INSIDE the answer text, so splitting it is not a formatting
|
|
1341
|
+
* step - it is a reading of the text, and the facts it produces ("the thinking started", "the thinking
|
|
1342
|
+
* ended") belong where the text is read.
|
|
1343
|
+
*
|
|
1344
|
+
* WHEN IT WAS FORGOTTEN, IT COST THE PRODUCT A BUG. The splitter used to live one layer up, in
|
|
1345
|
+
* `answer()`, where it covered EVERY path. It was moved down into the SDK session path to fix the
|
|
1346
|
+
* ordering of "the thinking ended" against a tool call (UX-008), and in that move the ONE-SHOT
|
|
1347
|
+
* CONVERSATION path - the path a server without the Pi SDK falls back to, and the path the whole test
|
|
1348
|
+
* suite drives - lost the splitting altogether: the `<think>` tags reached the answer verbatim.
|
|
1349
|
+
* `scripts/inline-thinking.test.mjs` caught it; the SDK path was fine, which is why it took a test
|
|
1350
|
+
* suite running the other path to notice.
|
|
1351
|
+
*
|
|
1352
|
+
* SO EVERY ONE-SHOT PATH GOES THROUGH HERE, and the session path keeps its own splitter inside the
|
|
1353
|
+
* queue's consumer, where the ORDER of "thinking ended" and a tool call is decided.
|
|
1354
|
+
*/
|
|
1355
|
+
async *answerOneShotWithInlineThinking(input) {
|
|
1356
|
+
const splitter = new InlineThinkingSplitter();
|
|
1357
|
+
for await (const chunk of this.answerOneShot(input)) {
|
|
1358
|
+
for (const segment of splitter.push(chunk)) {
|
|
1359
|
+
if (segment.kind === "text")
|
|
1360
|
+
yield segment.text;
|
|
1361
|
+
else if (segment.kind === "thinking")
|
|
1362
|
+
await input.onThinking?.(segment.text);
|
|
1363
|
+
else
|
|
1364
|
+
await input.onThinkingEnd?.();
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
// THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed region
|
|
1368
|
+
// behind, and it belongs in the block rather than in the answer.
|
|
1369
|
+
for (const segment of splitter.flush()) {
|
|
1370
|
+
if (segment.kind === "text")
|
|
1371
|
+
yield segment.text;
|
|
1372
|
+
else if (segment.kind === "thinking")
|
|
1373
|
+
await input.onThinking?.(segment.text);
|
|
1374
|
+
else
|
|
1375
|
+
await input.onThinkingEnd?.();
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1295
1378
|
/**
|
|
1296
1379
|
* The one-shot path: a fresh child process for a single turn.
|
|
1297
1380
|
*
|
|
@@ -1379,7 +1462,7 @@ export class PiRpcRuntime {
|
|
|
1379
1462
|
// allowlist and naming a tool Pi has not registered would change the spawn line for every other
|
|
1380
1463
|
// mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
|
|
1381
1464
|
// tool call anyway the reranker times out and falls back to the lexical order.
|
|
1382
|
-
const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
|
|
1465
|
+
const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register", ...(this.options.orchestratorEndpoint ? [SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME] : [])] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
|
|
1383
1466
|
// Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
|
|
1384
1467
|
// "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
|
|
1385
1468
|
if (!this.pi.found)
|
|
@@ -1967,6 +2050,23 @@ export class PiRpcRuntime {
|
|
|
1967
2050
|
* revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
|
|
1968
2051
|
* elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
|
|
1969
2052
|
*/
|
|
2053
|
+
/**
|
|
2054
|
+
* THE LIVE SESSION MUST GO WHEN A REVERT REWRITES THE TRANSCRIPT.
|
|
2055
|
+
*
|
|
2056
|
+
* A revert rewrites Pi's transcript on disk. A live Pi session holds the OLD context in memory, and
|
|
2057
|
+
* nothing in Pi would notice the difference: the model would keep answering from history the user
|
|
2058
|
+
* has just withdrawn, while `state.json` and the file on disk both said it was gone. Three views of
|
|
2059
|
+
* the conversation, two of them wrong, and no error anywhere. So the session is destroyed and the
|
|
2060
|
+
* next turn rebuilds it from the truncated file.
|
|
2061
|
+
*
|
|
2062
|
+
* THIS IS A CORRECTNESS REQUIREMENT, NOT AN OPTIMIZATION, and it is why it lives in one place
|
|
2063
|
+
* called by EVERY branch of `revert`. It used to be inline in two of the three, and the third -
|
|
2064
|
+
* the one taken when the transcript path was not cached - missed it, so a session reverted before
|
|
2065
|
+
* its path was ever learned kept talking about the withdrawn turn with nothing saying so.
|
|
2066
|
+
*/
|
|
2067
|
+
async evictAfterRevert(conversationId) {
|
|
2068
|
+
await this.sessionPool.evict(conversationId);
|
|
2069
|
+
}
|
|
1970
2070
|
async revert({ conversation, cwd, boundaryEntryId, beforeBoundary }) {
|
|
1971
2071
|
if (this.runs.has(conversation.id) || this.activeTurns.has(conversation.id))
|
|
1972
2072
|
throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
|
|
@@ -1981,14 +2081,7 @@ export class PiRpcRuntime {
|
|
|
1981
2081
|
const exists = await stat(cached).catch(() => undefined);
|
|
1982
2082
|
if (exists?.isFile()) {
|
|
1983
2083
|
const result = await truncateSessionFile(cached, boundaryEntryId, conversation.id, beforeBoundary);
|
|
1984
|
-
|
|
1985
|
-
// holds the OLD context in memory. Nothing in Pi would notice the difference: the model would
|
|
1986
|
-
// keep answering from history the user has just withdrawn, while `state.json` and the file on
|
|
1987
|
-
// disk both said it was gone. Three views of the conversation, two of them wrong, and no error
|
|
1988
|
-
// anywhere - so the session is destroyed here and the next turn rebuilds it from the truncated
|
|
1989
|
-
// file. This is the one place where eviction is a correctness requirement and not an
|
|
1990
|
-
// optimization.
|
|
1991
|
-
await this.sessionPool.evict(conversation.id);
|
|
2084
|
+
await this.evictAfterRevert(conversation.id);
|
|
1992
2085
|
return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
1993
2086
|
}
|
|
1994
2087
|
}
|
|
@@ -2014,7 +2107,7 @@ export class PiRpcRuntime {
|
|
|
2014
2107
|
if (!found?.isFile())
|
|
2015
2108
|
throw new Error(`Pi 还没有为这个会话建立记录文件(${resolved}),没有可以回退的历史。`);
|
|
2016
2109
|
const result = await truncateSessionFile(resolved, boundaryEntryId, conversation.id, beforeBoundary);
|
|
2017
|
-
await this.
|
|
2110
|
+
await this.evictAfterRevert(conversation.id);
|
|
2018
2111
|
return { sessionFile: resolved, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
2019
2112
|
}
|
|
2020
2113
|
// NO agent flags and NO extension on this spawn. It sends no prompt and must leave nothing behind:
|
|
@@ -2065,6 +2158,7 @@ export class PiRpcRuntime {
|
|
|
2065
2158
|
if (!exists?.isFile())
|
|
2066
2159
|
throw new Error(`Pi 还没有为这个会话建立记录文件(${sessionFile}),没有可以回退的历史。`);
|
|
2067
2160
|
const result = await truncateSessionFile(sessionFile, boundaryEntryId, conversation.id, beforeBoundary);
|
|
2161
|
+
await this.evictAfterRevert(conversation.id);
|
|
2068
2162
|
return { sessionFile, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
|
|
2069
2163
|
}
|
|
2070
2164
|
/**
|
|
@@ -2236,12 +2330,21 @@ export class PiRpcRuntime {
|
|
|
2236
2330
|
const turn = this.activeTurns.get(conversationId);
|
|
2237
2331
|
if (turn) {
|
|
2238
2332
|
turn.abort();
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
|
|
2333
|
+
// abort() can resolve during prompt preflight, before any agent exists. Await the prompt call
|
|
2334
|
+
// as well, which includes that preflight and the actual run in Pi 0.86. For adapters returning
|
|
2335
|
+
// earlier, abortDone still waits for idle. Do not wait for the caller to drain the event queue:
|
|
2336
|
+
// a caller may stop while paused at a yielded delta. A deadline bounds only this request;
|
|
2337
|
+
// RunService retains the real run slot until the reader ends.
|
|
2338
|
+
let stopDeadline;
|
|
2339
|
+
try {
|
|
2340
|
+
await Promise.race([Promise.all([turn.promptFinished, turn.abortDone]), new Promise((resolvePromise) => {
|
|
2341
|
+
stopDeadline = setTimeout(resolvePromise, 5_000);
|
|
2342
|
+
})]);
|
|
2343
|
+
}
|
|
2344
|
+
finally {
|
|
2345
|
+
if (stopDeadline !== undefined)
|
|
2346
|
+
clearTimeout(stopDeadline);
|
|
2347
|
+
}
|
|
2245
2348
|
return;
|
|
2246
2349
|
}
|
|
2247
2350
|
// The one-shot path: a child process that has to be told to abort over its stdin.
|
|
@@ -71,6 +71,8 @@ export interface ServerContext {
|
|
|
71
71
|
* task of its own.
|
|
72
72
|
*/
|
|
73
73
|
scheduledTaskService?: import("./application/scheduled-task-service.js").ScheduledTaskService;
|
|
74
|
+
sessionLedger?: import("./session-ledger.js").SessionLedger;
|
|
75
|
+
sessionOrchestratorService?: import("./application/session-orchestrator-service.js").SessionOrchestratorService;
|
|
74
76
|
execService: ExecService;
|
|
75
77
|
toolRegistry: ToolRegistry;
|
|
76
78
|
toolRunner: ToolRunner;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type SessionOrigin = "user" | "orchestrator";
|
|
2
|
+
export type SessionLedgerStatus = "idle" | "running" | "waiting" | "stopped" | "failed" | "completed";
|
|
3
|
+
/**
|
|
4
|
+
* Metadata entry for a conversation managed or observed by the orchestrator.
|
|
5
|
+
* Crucially, NO transcript or message history is retained here to keep memory footprint bounded.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionLedgerEntry {
|
|
8
|
+
conversationId: string;
|
|
9
|
+
workspaceId: string;
|
|
10
|
+
/** Whether created manually by user or by orchestrator agent */
|
|
11
|
+
origin: SessionOrigin;
|
|
12
|
+
/** If created by orchestrator: the parent conversation that dispatched it */
|
|
13
|
+
parentConversationId?: string;
|
|
14
|
+
/** One-line task description assigned to this session */
|
|
15
|
+
task?: string;
|
|
16
|
+
/** Current status */
|
|
17
|
+
status: SessionLedgerStatus;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
updatedAt: number;
|
|
20
|
+
/** High-level progress snippet or latest outcome */
|
|
21
|
+
lastProgress?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* In-memory relationship ledger for sibling and child sessions.
|
|
25
|
+
* Holds only metadata (parent-child relationships, tasks, statuses) - never transcripts.
|
|
26
|
+
*/
|
|
27
|
+
export declare class SessionLedger {
|
|
28
|
+
private readonly entries;
|
|
29
|
+
/**
|
|
30
|
+
* Registers a session into the ledger.
|
|
31
|
+
*/
|
|
32
|
+
register(entry: Omit<SessionLedgerEntry, "createdAt" | "updatedAt"> & {
|
|
33
|
+
createdAt?: number;
|
|
34
|
+
updatedAt?: number;
|
|
35
|
+
}): SessionLedgerEntry;
|
|
36
|
+
/**
|
|
37
|
+
* Updates metadata for an existing session in the ledger.
|
|
38
|
+
*/
|
|
39
|
+
update(conversationId: string, patch: Partial<Omit<SessionLedgerEntry, "conversationId" | "createdAt">>): SessionLedgerEntry | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Retrieves metadata for one session.
|
|
42
|
+
*/
|
|
43
|
+
get(conversationId: string): SessionLedgerEntry | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Lists all sessions dispatched by a given parent conversation, sorted newest first.
|
|
46
|
+
*/
|
|
47
|
+
listByParent(parentConversationId: string): SessionLedgerEntry[];
|
|
48
|
+
/**
|
|
49
|
+
* Lists all recorded sessions in the ledger, sorted newest first.
|
|
50
|
+
*/
|
|
51
|
+
listAll(): SessionLedgerEntry[];
|
|
52
|
+
/**
|
|
53
|
+
* Removes a session from the ledger.
|
|
54
|
+
*/
|
|
55
|
+
delete(conversationId: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Clears all ledger entries (primarily for test cleanup).
|
|
58
|
+
*/
|
|
59
|
+
clear(): void;
|
|
60
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory relationship ledger for sibling and child sessions.
|
|
3
|
+
* Holds only metadata (parent-child relationships, tasks, statuses) - never transcripts.
|
|
4
|
+
*/
|
|
5
|
+
export class SessionLedger {
|
|
6
|
+
entries = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Registers a session into the ledger.
|
|
9
|
+
*/
|
|
10
|
+
register(entry) {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const fullEntry = {
|
|
13
|
+
...entry,
|
|
14
|
+
createdAt: entry.createdAt ?? now,
|
|
15
|
+
updatedAt: entry.updatedAt ?? now,
|
|
16
|
+
};
|
|
17
|
+
this.entries.set(entry.conversationId, fullEntry);
|
|
18
|
+
return { ...fullEntry };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Updates metadata for an existing session in the ledger.
|
|
22
|
+
*/
|
|
23
|
+
update(conversationId, patch) {
|
|
24
|
+
const existing = this.entries.get(conversationId);
|
|
25
|
+
if (!existing)
|
|
26
|
+
return undefined;
|
|
27
|
+
const updated = {
|
|
28
|
+
...existing,
|
|
29
|
+
...patch,
|
|
30
|
+
updatedAt: Date.now(),
|
|
31
|
+
};
|
|
32
|
+
this.entries.set(conversationId, updated);
|
|
33
|
+
return { ...updated };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Retrieves metadata for one session.
|
|
37
|
+
*/
|
|
38
|
+
get(conversationId) {
|
|
39
|
+
const entry = this.entries.get(conversationId);
|
|
40
|
+
return entry ? { ...entry } : undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Lists all sessions dispatched by a given parent conversation, sorted newest first.
|
|
44
|
+
*/
|
|
45
|
+
listByParent(parentConversationId) {
|
|
46
|
+
const result = [];
|
|
47
|
+
for (const entry of this.entries.values()) {
|
|
48
|
+
if (entry.parentConversationId === parentConversationId) {
|
|
49
|
+
result.push({ ...entry });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result.sort((a, b) => b.createdAt - a.createdAt);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Lists all recorded sessions in the ledger, sorted newest first.
|
|
56
|
+
*/
|
|
57
|
+
listAll() {
|
|
58
|
+
const result = [];
|
|
59
|
+
for (const entry of this.entries.values()) {
|
|
60
|
+
result.push({ ...entry });
|
|
61
|
+
}
|
|
62
|
+
return result.sort((a, b) => b.createdAt - a.createdAt);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Removes a session from the ledger.
|
|
66
|
+
*/
|
|
67
|
+
delete(conversationId) {
|
|
68
|
+
return this.entries.delete(conversationId);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Clears all ledger entries (primarily for test cleanup).
|
|
72
|
+
*/
|
|
73
|
+
clear() {
|
|
74
|
+
this.entries.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|