@evandu/super-pi 0.1.88 → 0.1.90
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/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +4 -4
- package/dist/client/assets/index-DgQRMp3j.css +1 -0
- package/dist/client/assets/{index-Bpy8UeU1.js → index-p1BjYE_T.js} +6 -6
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +18 -0
- package/dist/server/application/run-service.js +224 -28
- package/dist/server/rpc-channel.d.ts +14 -1
- package/dist/server/rpc-channel.js +20 -2
- package/dist/server/store.d.ts +35 -18
- package/dist/server/store.js +69 -27
- package/package.json +1 -1
- package/dist/client/assets/index-CC-Q5gNQ.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-p1BjYE_T.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DgQRMp3j.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -99,6 +99,12 @@ export declare class RunService {
|
|
|
99
99
|
/**
|
|
100
100
|
* Called by the exec completion handler in index.ts when a background exec session ends.
|
|
101
101
|
* Injects the result into the conversation and cleans up the background task record.
|
|
102
|
+
*
|
|
103
|
+
* The result is now a REAL queue item, not a one-way notice the model never gets to read: it lands
|
|
104
|
+
* with `delivery: "waiting"` (so the dock shows it), at the front of the waiting set (so any pending
|
|
105
|
+
* user sends wait behind it), and the conversation auto-starts a turn for the agent to handle it
|
|
106
|
+
* when nothing else is busy. The render stays as a 后台回传 block throughout - in dock and in
|
|
107
|
+
* transcript - so a `role: "user"` is never impersonated.
|
|
102
108
|
*/
|
|
103
109
|
enqueueExecResult(sessionId: string, exitCode: number, stdout: string, stderr: string): void;
|
|
104
110
|
/** Per-conversation command lane. All queue mutations and run claims use the same lane, so
|
|
@@ -233,6 +239,18 @@ export declare class RunService {
|
|
|
233
239
|
steer(conversationId: string, messageId: string): Promise<void>;
|
|
234
240
|
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
235
241
|
sendQueued(conversationId: string, messageId: string): Promise<void>;
|
|
242
|
+
/**
|
|
243
|
+
* The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
|
|
244
|
+
*
|
|
245
|
+
* Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
|
|
246
|
+
* before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
|
|
247
|
+
* branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
|
|
248
|
+
* only in who arrived first; the open-turn-and-run contract is the same.
|
|
249
|
+
*
|
|
250
|
+
* `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
|
|
251
|
+
* transcript, but the prose form of the same fact is what reaches Pi.
|
|
252
|
+
*/
|
|
253
|
+
private processWaitingMessage;
|
|
236
254
|
private steerUnsafe;
|
|
237
255
|
/**
|
|
238
256
|
* Withdraws a waiting message.
|
|
@@ -7,6 +7,66 @@ import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
|
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* Translates a thrown error from the turn loop into a sentence a person reads.
|
|
12
|
+
*
|
|
13
|
+
* The turn loop throws for very different reasons: a model-provider rate limit, a 5xx that Pi's RPC
|
|
14
|
+
* layer chose to surface as a child exit, an OOM that took the whole process down, a path that
|
|
15
|
+
* resolved to a directory the workspace contract forbids, an `imageInput: false` mismatch. The
|
|
16
|
+
* throw message carries the technical fact; what the reader needs is the action it implies - retry,
|
|
17
|
+
* change model, check the key, change workspace - and that is the level this function writes at.
|
|
18
|
+
*
|
|
19
|
+
* The reason the shape is match-on-substring rather than match-on-error-class is that the upstream
|
|
20
|
+
* error is whatever Pi wrote to its own log: a string, not an exception class, and the host has
|
|
21
|
+
* nothing to pattern-match against other than the message itself. The literals below are
|
|
22
|
+
* deliberately permissive on the LLM side ("quota", "rate limit", "usage limit", "too many requests",
|
|
23
|
+
* "5-hour", "5h") because the underlying model APIs name this limit in several ways, and the
|
|
24
|
+
* reader cares about the bucket, not the exact wording.
|
|
25
|
+
*/
|
|
26
|
+
function formatTurnFailure(error) {
|
|
27
|
+
if (controllerAborted(error))
|
|
28
|
+
return "本轮任务已停止。";
|
|
29
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
30
|
+
const lower = raw.toLowerCase();
|
|
31
|
+
// PROVIDER QUOTA / RATE LIMIT - the only reason the user is in this branch is because Pi was
|
|
32
|
+
// answering them a moment ago and now the model will not let it. The action is "wait or change
|
|
33
|
+
// model"; the wording has to make that obvious from the banner alone, without a tooltip.
|
|
34
|
+
if (/quota|rate limit|usage limit|too many requests|5-hour|5h|resource_exhausted|resourc exhaust|hit your limit|usage cap|usage restrict/.test(lower)) {
|
|
35
|
+
return "模型额度已用完(5 小时限额或类似上限),Pi 已停止回答。可以等待重置后重试,或在会话里换一个模型再发。";
|
|
36
|
+
}
|
|
37
|
+
// RPC CHANNEL DEAD - Pi died without producing a `settled` event. Anything in the turn is now
|
|
38
|
+
// unanswerable, but the deltas the user already saw are still on screen, so the message has to
|
|
39
|
+
// say "we noticed and stopped" rather than "your input was lost".
|
|
40
|
+
if (lower.includes("pi rpc channel closed") || lower.includes("agent rpc") || lower.includes("exited before settling")) {
|
|
41
|
+
return "Pi 进程异常退出,未完成本轮回答。已停止运行,可以直接重发任务继续。";
|
|
42
|
+
}
|
|
43
|
+
// NO PI - the launch path failed (binary missing, executable bit, Windows shim). The reader's
|
|
44
|
+
// best move is a `which pi` / reinstall, and the banner should point there.
|
|
45
|
+
if (lower.includes("could not find") && lower.includes("pi") || lower.includes("pi command") || lower.startsWith("pi ")) {
|
|
46
|
+
return `无法启动 Pi:${raw || "未知原因"}。请确认 Pi 已正确安装并可在命令行运行。`;
|
|
47
|
+
}
|
|
48
|
+
// WORKSPACE PATH - turn refused on directory the contract forbids. Refusal happens before
|
|
49
|
+
// anything is on screen, so the banner is also the only place the reason lives.
|
|
50
|
+
if (lower.includes("workspace directory") || lower.includes("does not exist or is not a directory")) {
|
|
51
|
+
return `工作区目录无效:${raw}`;
|
|
52
|
+
}
|
|
53
|
+
// IMAGE MISMATCH - explicit by the caller, no need to translate.
|
|
54
|
+
if (lower.includes("不支持图片输入")) {
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
// GENERIC - the literal message is more useful than a paraphrase, but it has to be the LAST
|
|
58
|
+
// branch and the message has to be non-empty (a thrown `new Error()` is empty and renders
|
|
59
|
+
// nothing, which is exactly the silence this whole change exists to break).
|
|
60
|
+
return raw || "Pi 未能完成本轮回答,已标记为失败。可以重发任务继续。";
|
|
61
|
+
}
|
|
62
|
+
function controllerAborted(error) {
|
|
63
|
+
if (!error)
|
|
64
|
+
return false;
|
|
65
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
66
|
+
return true;
|
|
67
|
+
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
68
|
+
return /abort|cancel|aborted/i.test(msg);
|
|
69
|
+
}
|
|
10
70
|
/** Expands composer references into explicit context for the model. */
|
|
11
71
|
async function expandAtReferences(store, workspace, content) {
|
|
12
72
|
const refs = [...content.matchAll(/@(worktree|session):([^\s]+)/gi)];
|
|
@@ -38,6 +98,35 @@ async function expandAtReferences(store, workspace, content) {
|
|
|
38
98
|
}
|
|
39
99
|
return blocks.length ? `${content}\n${blocks.join("\n")}` : content;
|
|
40
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The text the model is handed when a waiting message becomes its turn.
|
|
103
|
+
*
|
|
104
|
+
* `role: "user"` is the user's own words - pass through verbatim. `role: "tool"` is a 后台回传 block
|
|
105
|
+
* whose `message.content` is the `<task>` envelope the transcript and dock render from; handing the raw
|
|
106
|
+
* XML to Pi asks it to parse the format itself, which is the "未定义行为" path that 0.1.66 worked around
|
|
107
|
+
* by simply not triggering a turn. We DO trigger a turn now (queue semantics), so we synthesise prose
|
|
108
|
+
* the model can read like any other user message.
|
|
109
|
+
*
|
|
110
|
+
* The envelope and the prose are NOT redundant: the envelope is what the reader sees in the transcript
|
|
111
|
+
* column, what they cancel or reorder; the prose is what leaves the server for Pi.
|
|
112
|
+
*/
|
|
113
|
+
function composePrompt(message) {
|
|
114
|
+
if (message.role !== "tool")
|
|
115
|
+
return message.content;
|
|
116
|
+
const content = message.content;
|
|
117
|
+
const summaryMatch = /<summary>([\s\S]*?)<\/summary>/.exec(content);
|
|
118
|
+
const summary = summaryMatch ? summaryMatch[1].trim() : "后台任务";
|
|
119
|
+
const stateMatch = /<task id="[^"]+" state="([^"]+)"/.exec(content);
|
|
120
|
+
const state = stateMatch ? stateMatch[1] : "completed";
|
|
121
|
+
const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*?)\n?<\/(?:task_result|task_error)>/.exec(content);
|
|
122
|
+
const body = bodyMatch ? bodyMatch[1] : content;
|
|
123
|
+
const stateLabel = state === "completed" ? "已完成" : state === "error" ? "失败" : state;
|
|
124
|
+
// The marker `@bgdelivery:` is at the end so the visible prose is what the model sees (it can ignore
|
|
125
|
+
// a trailing `@`-token, which is what its other tools do too). The fake-pi fixture recognises the
|
|
126
|
+
// marker and settles immediately so test paths through this turn do not hang on the BLOCKING
|
|
127
|
+
// extension-UI default. A real model treats the marker as part of the user's prose.
|
|
128
|
+
return `[后台任务「${summary}」${stateLabel}]\n\n${body}\n\n@bgdelivery:${state}`;
|
|
129
|
+
}
|
|
41
130
|
/**
|
|
42
131
|
* Service managing execution of conversation turns, message queueing,
|
|
43
132
|
* follow-ups, and sub-agent dispatching.
|
|
@@ -127,6 +216,12 @@ export class RunService {
|
|
|
127
216
|
/**
|
|
128
217
|
* Called by the exec completion handler in index.ts when a background exec session ends.
|
|
129
218
|
* Injects the result into the conversation and cleans up the background task record.
|
|
219
|
+
*
|
|
220
|
+
* The result is now a REAL queue item, not a one-way notice the model never gets to read: it lands
|
|
221
|
+
* with `delivery: "waiting"` (so the dock shows it), at the front of the waiting set (so any pending
|
|
222
|
+
* user sends wait behind it), and the conversation auto-starts a turn for the agent to handle it
|
|
223
|
+
* when nothing else is busy. The render stays as a 后台回传 block throughout - in dock and in
|
|
224
|
+
* transcript - so a `role: "user"` is never impersonated.
|
|
130
225
|
*/
|
|
131
226
|
enqueueExecResult(sessionId, exitCode, stdout, stderr) {
|
|
132
227
|
const tasks = this.store.snapshot().backgroundTasks;
|
|
@@ -135,10 +230,6 @@ export class RunService {
|
|
|
135
230
|
return;
|
|
136
231
|
const tag = exitCode === 0 ? "task_result" : "task_error";
|
|
137
232
|
const body = exitCode === 0 ? stdout : (stderr || `Command exited with code ${exitCode}`);
|
|
138
|
-
// The result becomes a `role: "tool"` message in the conversation, not a user prompt for a fresh
|
|
139
|
-
// turn. The transcript now has a 「后台回传」 column the client renders, and the model sees the
|
|
140
|
-
// result only when something else starts a turn that includes it. Asking the model to consume a
|
|
141
|
-
// `<task>` envelope as user prose is the "undefined behaviour" path the user reported.
|
|
142
233
|
const message = `<task id="${sessionId}" state="${exitCode === 0 ? "completed" : "error"}" kind="exec">\n<summary>${task.title}</summary>\n<${tag}>\n${body}</${tag}>\n</task>`;
|
|
143
234
|
void this.trackDetached((async () => {
|
|
144
235
|
try {
|
|
@@ -151,8 +242,31 @@ export class RunService {
|
|
|
151
242
|
this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
|
|
152
243
|
}
|
|
153
244
|
this.broadcaster.broadcast({ type: "exec.exit", sessionId, exitCode, stdout, stderr });
|
|
154
|
-
|
|
245
|
+
// QUEUE ITEM, NOT A NOTICE. `delivery: "waiting"` is what makes the row show in the dock; without
|
|
246
|
+
// it the message is a transcript entry only and `takeNextWaitingMessage` (which `runConversationTurns`
|
|
247
|
+
// calls after every turn) never picks it up.
|
|
248
|
+
const queued = await this.store.addToolMessage(task.conversationId, message, { delivery: "waiting" });
|
|
155
249
|
this.broadcaster.broadcastState();
|
|
250
|
+
// STEER PRIORITY. If anything else is already waiting, jump this one to the front of the queue
|
|
251
|
+
// so a 后台回传 cuts ahead of user sends - matching "按插队逻辑来处理".
|
|
252
|
+
const waiting = this.store.listWaitingMessages(task.conversationId);
|
|
253
|
+
if (waiting.length > 1) {
|
|
254
|
+
await this.store.reorderWaitingMessage(task.conversationId, queued.id, 0);
|
|
255
|
+
this.broadcaster.broadcastState();
|
|
256
|
+
}
|
|
257
|
+
// AUTO-TRIGGER WHEN IDLE. The conversation may already be busy (running turn, taking the next
|
|
258
|
+
// waiting message in its own follow-up loop); if so, the existing loop picks this one up and
|
|
259
|
+
// this branch is a no-op. If idle, kick off a fresh turn so the model processes the result
|
|
260
|
+
// without the user having to send anything - the failure case is intentionally included: the
|
|
261
|
+
// model seeing "[后台任务 X 失败]" is part of the same "after current AI finishes, automatically
|
|
262
|
+
// processes the result" contract, and the auto-triggered turn goes through the same
|
|
263
|
+
// `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
|
|
264
|
+
// prose the agent can react to).
|
|
265
|
+
if (!this.activeRuns.has(task.conversationId)) {
|
|
266
|
+
void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
|
|
267
|
+
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
156
270
|
}
|
|
157
271
|
catch (error) {
|
|
158
272
|
console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -810,7 +924,9 @@ export class RunService {
|
|
|
810
924
|
const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
|
|
811
925
|
this.broadcaster.broadcastState();
|
|
812
926
|
const current = this.turnContext(conversation.id).conversation;
|
|
813
|
-
|
|
927
|
+
// `composePrompt` synthesises the prose for a 后台回传 (the `<task>` envelope is what the transcript
|
|
928
|
+
// column reads from; the prose is what leaves the server for Pi). For user messages it is a no-op.
|
|
929
|
+
await this.streamTurn(current, workspace, composePrompt(next), followUp.assistant, controller, followUp.user.id, next.images, identity);
|
|
814
930
|
}
|
|
815
931
|
}
|
|
816
932
|
finally {
|
|
@@ -893,13 +1009,61 @@ export class RunService {
|
|
|
893
1009
|
// first line, so it is outside this try for exactly that reason. A failure from in there is a turn
|
|
894
1010
|
// that has already consumed these words, and putting them back would send them twice.
|
|
895
1011
|
await this.store.markMessageWaiting(conversationId, message.id).catch(() => undefined);
|
|
1012
|
+
// The reason for the refusal is also worth showing - a banner above the transcript is the only
|
|
1013
|
+
// place the user can read it from, because the message itself was rolled back to the queue and
|
|
1014
|
+
// the banner is the difference between "the message is waiting" and "I know why it is
|
|
1015
|
+
// waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
|
|
1016
|
+
// survive a retry into the same conversation.
|
|
1017
|
+
await this.store.setFailureReason(conversationId, formatTurnFailure(error));
|
|
1018
|
+
// Status is restored here, not relied on the outer catch to handle: a preflight failure never
|
|
1019
|
+
// opened a turn, so the right state is the one the conversation had a moment ago (idle). The
|
|
1020
|
+
// outer catch would otherwise flip it to `failed`, which the reader would render as a turn
|
|
1021
|
+
// that ran and broke - the opposite of what actually happened.
|
|
1022
|
+
await this.store.setStatus(conversationId, "idle");
|
|
1023
|
+
// Tag the error so the outer catch knows to stay out of the way: no status flip, no second
|
|
1024
|
+
// setFailureReason (the one above is the only one the reader should see). A free-standing
|
|
1025
|
+
// property on the Error object is enough - it is checked with `in`, not by class, and the
|
|
1026
|
+
// throw immediately below carries it through the propagation.
|
|
1027
|
+
error.__superPiPreflight = true;
|
|
1028
|
+
this.broadcaster.broadcastState();
|
|
896
1029
|
throw error;
|
|
897
1030
|
}
|
|
898
1031
|
await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
|
|
899
1032
|
await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
|
|
900
1033
|
}
|
|
901
1034
|
catch (error) {
|
|
1035
|
+
// A failing turn needs to be READ AS A FAILING TURN, not just stamped "failed" in a corner no one
|
|
1036
|
+
// watches. The status flip is necessary but not sufficient: a turn that was answering the user a
|
|
1037
|
+
// moment ago, now reporting 「failed」 with no reason, looks identical to a turn that the user
|
|
1038
|
+
// stopped themselves, and the reader has no way to tell which one it is. The only honest move
|
|
1039
|
+
// is to put the reason in front of them - `Conversation.error` is the banner above the
|
|
1040
|
+
// transcript that the failure state was never reaching.
|
|
1041
|
+
//
|
|
1042
|
+
// The reason is taken from the error itself, with the most common upstream cases translated to
|
|
1043
|
+
// the wording a person reads. The literal `error.message` would say "Pi RPC channel closed
|
|
1044
|
+
// before producing a record (code 3)" - that is true and useless. The translate here is what
|
|
1045
|
+
// the model-provider documentation actually says and what the reader can act on (retry, change
|
|
1046
|
+
// model, check the key), and it matches the banner the reader sees elsewhere in the app.
|
|
1047
|
+
//
|
|
1048
|
+
// NOT EVERY CATCH PATH IS A FAILURE the reader should see. A stop signalled through the
|
|
1049
|
+
// controller reaches the catch too (the abort propagates as a throw), and a stop is not a
|
|
1050
|
+
// failure - it is what the user asked for, and painting it red would be both wrong and a
|
|
1051
|
+
// regression from the pre-change behavior of these very lines. The reason is recorded only
|
|
1052
|
+
// when the controller was NOT aborted.
|
|
1053
|
+
//
|
|
1054
|
+
// Preflight refusals - bad model choice, unresolvable `@`-reference - carry the same shape but
|
|
1055
|
+
// never opened a turn, so flipping status to `failed` would be a lie: the previous state was
|
|
1056
|
+
// `idle` (or whatever the conversation was before the send) and that is what the conversation
|
|
1057
|
+
// is now. The preflight branch above already wrote the reason and set status back to `idle`,
|
|
1058
|
+
// so this catch only has to stay out of the way. The marker is set on the error there and
|
|
1059
|
+
// checked here.
|
|
1060
|
+
if (error?.__superPiPreflight) {
|
|
1061
|
+
throw error;
|
|
1062
|
+
}
|
|
902
1063
|
await this.store.setStatus(conversation.id, "failed");
|
|
1064
|
+
if (!controller.signal.aborted) {
|
|
1065
|
+
await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
|
|
1066
|
+
}
|
|
903
1067
|
throw error;
|
|
904
1068
|
}
|
|
905
1069
|
finally {
|
|
@@ -916,22 +1080,29 @@ export class RunService {
|
|
|
916
1080
|
}
|
|
917
1081
|
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
918
1082
|
async sendQueued(conversationId, messageId) {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1083
|
+
// A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
|
|
1084
|
+
// short cleanup window; a click on the newly visible button must not race the old run slot.
|
|
1085
|
+
const deadline = Date.now() + 15_000;
|
|
1086
|
+
while (this.activeRuns.has(conversationId) && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status === "stopped" && Date.now() < deadline) {
|
|
1087
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1088
|
+
}
|
|
1089
|
+
await this.processWaitingMessage(conversationId, messageId);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
|
|
1093
|
+
*
|
|
1094
|
+
* Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
|
|
1095
|
+
* before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
|
|
1096
|
+
* branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
|
|
1097
|
+
* only in who arrived first; the open-turn-and-run contract is the same.
|
|
1098
|
+
*
|
|
1099
|
+
* `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
|
|
1100
|
+
* transcript, but the prose form of the same fact is what reaches Pi.
|
|
1101
|
+
*/
|
|
1102
|
+
async processWaitingMessage(conversationId, messageId) {
|
|
928
1103
|
return this.serializeConversation(conversationId, async () => {
|
|
929
1104
|
if (this.activeRuns.has(conversationId))
|
|
930
1105
|
throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
|
|
931
|
-
// Claim the run slot BEFORE releasing the command lane, then start the turn from the message that
|
|
932
|
-
// is already in the transcript. It is not re-sent through `enqueueAndRun`, which would write a
|
|
933
|
-
// SECOND copy of these words: the whole point of this action is that the user is sending a message
|
|
934
|
-
// they can already see sitting in their conversation.
|
|
935
1106
|
const controller = new AbortController();
|
|
936
1107
|
this.activeRuns.set(conversationId, controller);
|
|
937
1108
|
try {
|
|
@@ -940,14 +1111,14 @@ export class RunService {
|
|
|
940
1111
|
const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
|
|
941
1112
|
const opened = await this.store.openTurnOnSpecificMessage(conversationId, messageId, identity);
|
|
942
1113
|
this.broadcaster.broadcastState();
|
|
943
|
-
await this.runConversationTurns(conversation, workspace, opened.user
|
|
1114
|
+
await this.runConversationTurns(conversation, workspace, composePrompt(opened.user), controller, opened.user.id, opened.user.images);
|
|
944
1115
|
await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
|
|
945
1116
|
}
|
|
946
1117
|
catch (error) {
|
|
947
1118
|
await this.store.setStatus(conversationId, "failed");
|
|
948
|
-
// A preflight failure must put the
|
|
949
|
-
// duplicate the
|
|
950
|
-
// there - which is what makes "the send failed,
|
|
1119
|
+
// A preflight failure must put the message BACK where the reader can see it. Re-adding it would
|
|
1120
|
+
// duplicate the row that is still in the transcript, so this only re-marks the one that is
|
|
1121
|
+
// there - which is what makes "the send failed, this row is still in the queue" a true statement.
|
|
951
1122
|
await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
|
|
952
1123
|
throw error;
|
|
953
1124
|
}
|
|
@@ -989,7 +1160,10 @@ export class RunService {
|
|
|
989
1160
|
const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
|
|
990
1161
|
this.broadcaster.broadcastState();
|
|
991
1162
|
try {
|
|
992
|
-
|
|
1163
|
+
// `composePrompt` turns a 后台回传 into the prose the model can read as a user message; for a
|
|
1164
|
+
// user waiting message it is the no-op identity. `claimed.images` is always undefined for a
|
|
1165
|
+
// `role: "tool"` row, and that is correct - 后台回传 carries no images today.
|
|
1166
|
+
await this.runtime.steer(conversationId, composePrompt(claimed), claimed.images);
|
|
993
1167
|
}
|
|
994
1168
|
catch (error) {
|
|
995
1169
|
// Back to waiting in every failure case, including the "Pi is gone" one. It is still at the front of
|
|
@@ -1115,11 +1289,24 @@ export class RunService {
|
|
|
1115
1289
|
this.memory?.review(this.turnContext(conversationId).conversation, { task: body.prompt, result: run.output });
|
|
1116
1290
|
const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
|
|
1117
1291
|
const tag = run.status === "completed" ? "task_result" : "task_error";
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1292
|
+
// A 后台回传 that the agent will actually get to read: same `<task>` envelope the transcript and
|
|
1293
|
+
// dock render from, but with `delivery: "waiting"` so it lives in the queue, jumps to the
|
|
1294
|
+
// front, and auto-triggers a turn when the conversation is idle. The render side is unchanged -
|
|
1295
|
+
// still the 后台回传 column.
|
|
1120
1296
|
const message = `<task id="${run.id}" state="${run.status === "completed" ? "completed" : "error"}" kind="subagent">\n<summary>Background task ${run.status === "completed" ? "completed" : "failed"}: ${body.description?.trim() || subagentType}</summary>\n<${tag}>\n${result}</${tag}>\n</task>`;
|
|
1121
1297
|
try {
|
|
1122
|
-
await this.store.addToolMessage(conversation.id, message);
|
|
1298
|
+
const queued = await this.store.addToolMessage(conversation.id, message, { delivery: "waiting" });
|
|
1299
|
+
this.broadcaster.broadcastState();
|
|
1300
|
+
const waiting = this.store.listWaitingMessages(conversation.id);
|
|
1301
|
+
if (waiting.length > 1) {
|
|
1302
|
+
await this.store.reorderWaitingMessage(conversation.id, queued.id, 0);
|
|
1303
|
+
this.broadcaster.broadcastState();
|
|
1304
|
+
}
|
|
1305
|
+
if (!this.activeRuns.has(conversation.id)) {
|
|
1306
|
+
void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
|
|
1307
|
+
console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1123
1310
|
}
|
|
1124
1311
|
catch (error) {
|
|
1125
1312
|
console.warn(`Super Pi: sub-agent result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -1129,7 +1316,16 @@ export class RunService {
|
|
|
1129
1316
|
}).catch(async (error) => {
|
|
1130
1317
|
const message = `<task id="${backgroundId}" state="error" kind="subagent">\n<summary>Background task failed: ${body.description?.trim() || subagentType}</summary>\n<task_error>Background task failed before completion: ${error instanceof Error ? error.message : "unknown error"}</task_error>\n</task>`;
|
|
1131
1318
|
try {
|
|
1132
|
-
await this.store.addToolMessage(conversationId, message);
|
|
1319
|
+
const queued = await this.store.addToolMessage(conversationId, message, { delivery: "waiting" });
|
|
1320
|
+
this.broadcaster.broadcastState();
|
|
1321
|
+
const waiting = this.store.listWaitingMessages(conversationId);
|
|
1322
|
+
if (waiting.length > 1) {
|
|
1323
|
+
await this.store.reorderWaitingMessage(conversationId, queued.id, 0);
|
|
1324
|
+
this.broadcaster.broadcastState();
|
|
1325
|
+
}
|
|
1326
|
+
// No auto-trigger on this failure branch (the result envelope says `state="error"` and the
|
|
1327
|
+
// conversation is by construction not in a turn that asked for it). The row is visible in the
|
|
1328
|
+
// dock for the reader to send-now or cancel.
|
|
1133
1329
|
}
|
|
1134
1330
|
catch (innerError) {
|
|
1135
1331
|
console.warn(`Super Pi: sub-agent failure could not be appended as a tool message: ${innerError instanceof Error ? innerError.message : "unknown error"}`);
|
|
@@ -96,7 +96,20 @@ export declare class RpcChannel {
|
|
|
96
96
|
write(command: object): void;
|
|
97
97
|
/** Returns a response to its waiter after the drain loop encountered it. */
|
|
98
98
|
stash(record: RpcRecord): void;
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Removes and returns the next record, or returns `undefined` after `timeoutMs` with nothing to read.
|
|
101
|
+
*
|
|
102
|
+
* THROWS once the child is gone, rather than returning undefined. The old contract was "undefined on
|
|
103
|
+
* EOF" and it had a specific shape of failure it could not see: Pi dying without ever emitting
|
|
104
|
+
* `agent_settled` (a 4xx/5xx from the model provider is the obvious one, an OOM kill another) used
|
|
105
|
+
* to look exactly like "Pi had nothing to say", so the host's drain loop kept polling until the
|
|
106
|
+
* generator returned and the turn was reported as completed - a quota-exhausted turn presented to
|
|
107
|
+
* the reader as 「已完成」 with no error and no message, which is the exact silence this threw exit
|
|
108
|
+
* exists to break. The thrown `Error` carries the exit reason and a `code: "CHANNEL_CLOSED"`
|
|
109
|
+
* property the runtime layer can pattern-match on when it wants to decide between "this turn
|
|
110
|
+
* failed before settling" and any other drain-loop failure; a host that does not match it still
|
|
111
|
+
* sees a real error with a real reason.
|
|
112
|
+
*/
|
|
100
113
|
next(timeoutMs?: number): Promise<RpcRecord | undefined>;
|
|
101
114
|
/** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
|
|
102
115
|
request(command: {
|
|
@@ -74,13 +74,31 @@ export class RpcChannel {
|
|
|
74
74
|
this.wake();
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Removes and returns the next record, or returns `undefined` after `timeoutMs` with nothing to read.
|
|
79
|
+
*
|
|
80
|
+
* THROWS once the child is gone, rather than returning undefined. The old contract was "undefined on
|
|
81
|
+
* EOF" and it had a specific shape of failure it could not see: Pi dying without ever emitting
|
|
82
|
+
* `agent_settled` (a 4xx/5xx from the model provider is the obvious one, an OOM kill another) used
|
|
83
|
+
* to look exactly like "Pi had nothing to say", so the host's drain loop kept polling until the
|
|
84
|
+
* generator returned and the turn was reported as completed - a quota-exhausted turn presented to
|
|
85
|
+
* the reader as 「已完成」 with no error and no message, which is the exact silence this threw exit
|
|
86
|
+
* exists to break. The thrown `Error` carries the exit reason and a `code: "CHANNEL_CLOSED"`
|
|
87
|
+
* property the runtime layer can pattern-match on when it wants to decide between "this turn
|
|
88
|
+
* failed before settling" and any other drain-loop failure; a host that does not match it still
|
|
89
|
+
* sees a real error with a real reason.
|
|
90
|
+
*/
|
|
78
91
|
async next(timeoutMs = 100) {
|
|
79
92
|
const deadline = performance.now() + timeoutMs;
|
|
80
93
|
for (;;) {
|
|
81
94
|
if (this.records.length)
|
|
82
95
|
return this.records.shift();
|
|
83
|
-
if (this.exited || this.child.exitCode !== null
|
|
96
|
+
if (this.exited || this.child.exitCode !== null) {
|
|
97
|
+
const err = new Error(`Pi RPC channel closed before producing a record (${this.exitReason || "no reason recorded"}). ${this.stderr.trim()}`);
|
|
98
|
+
err.code = "CHANNEL_CLOSED";
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
if (performance.now() >= deadline)
|
|
84
102
|
return undefined;
|
|
85
103
|
await this.changed(deadline - performance.now());
|
|
86
104
|
}
|
package/dist/server/store.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AppStateRepository } from "./persistence/app-state-repository.js";
|
|
2
2
|
import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
|
|
3
3
|
import type { WorkflowRun } from "./workflow.js";
|
|
4
|
-
import type { AppState, Conversation, Message, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
|
|
4
|
+
import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
|
|
5
5
|
/** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
|
|
6
6
|
*
|
|
7
7
|
* Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
|
|
@@ -220,18 +220,18 @@ export declare class Store {
|
|
|
220
220
|
*/
|
|
221
221
|
private claimClientMessageId;
|
|
222
222
|
/**
|
|
223
|
-
* Appends one background-result message to a conversation
|
|
223
|
+
* Appends one background-result message to a conversation. The render column is the same as before
|
|
224
|
+
* (a `role: "tool"` row, never impersonating a user bubble) - what this method now ALSO supports is
|
|
225
|
+
* putting the message straight into the dock by passing `delivery: "waiting"`, which is what exec and
|
|
226
|
+
* sub-agent background completions do once a 后台回传 is treated as a queue item with steer priority
|
|
227
|
+
* rather than a one-way notice the model never gets to read.
|
|
224
228
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* the "你" bubble and asked the model to consume a tool envelope as user prose. This appends a
|
|
228
|
-
* `role: "tool"` message that the client renders with its own column and its own block UI, so the
|
|
229
|
-
* transcript shows the result AND nothing pretends the user said it.
|
|
230
|
-
*
|
|
231
|
-
* NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript and a subsequent
|
|
232
|
-
* caller can start a turn normally if it wants the model to react to the result.
|
|
229
|
+
* NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript; with a delivery
|
|
230
|
+
* state it also enters `waitingMessages` and `runConversationTurns` will pick it up next.
|
|
233
231
|
*/
|
|
234
|
-
addToolMessage(conversationId: string, content: string
|
|
232
|
+
addToolMessage(conversationId: string, content: string, options?: {
|
|
233
|
+
delivery?: MessageDelivery;
|
|
234
|
+
}): Promise<Message>;
|
|
235
235
|
/**
|
|
236
236
|
* Records the model a conversation runs on. `updatedAt` is deliberately NOT touched, for the same
|
|
237
237
|
* reason rename does not touch it: it is the sidebar's recency key, and switching model is not
|
|
@@ -365,6 +365,22 @@ export declare class Store {
|
|
|
365
365
|
*/
|
|
366
366
|
clearPreparingToolCalls(conversationId: string, messageId: string, rows: readonly number[]): Promise<ToolCallBlock[]>;
|
|
367
367
|
setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
|
|
368
|
+
/**
|
|
369
|
+
* Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
|
|
370
|
+
* banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
|
|
371
|
+
* a status flip and a navigation away and back - so it is the only place a "Pi died without saying
|
|
372
|
+
* anything" failure becomes visible to a person.
|
|
373
|
+
*
|
|
374
|
+
* Settled through `persistMetadata` rather than `persistState` so the message and tool deltas the
|
|
375
|
+
* turn had already written are not re-validated against a partial read; a previous build took the
|
|
376
|
+
* heavier path and an early failure mid-turn left the transcript rolled back to the last good
|
|
377
|
+
* checkpoint, hiding exactly the deltas the reader would have wanted to see.
|
|
378
|
+
*
|
|
379
|
+
* Set to `undefined` to clear, which the next `addUserMessage` does anyway - so callers only need
|
|
380
|
+
* this when they want the banner without a follow-up message (the turn did fail and the user is
|
|
381
|
+
* going to act on it without sending another prompt first).
|
|
382
|
+
*/
|
|
383
|
+
setFailureReason(conversationId: string, reason: string | undefined): Promise<void>;
|
|
368
384
|
/** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
|
|
369
385
|
planRevert(conversationId: string, messageId: string, withdraw?: boolean): RevertPlan;
|
|
370
386
|
/**
|
|
@@ -405,7 +421,11 @@ export declare class Store {
|
|
|
405
421
|
* browsers and the array is mutated in place by the very next send.
|
|
406
422
|
*/
|
|
407
423
|
listWaitingMessages(conversationId: string): Message[];
|
|
408
|
-
/** The same list by reference, for callers that only read a field and must not pay for a clone.
|
|
424
|
+
/** The same list by reference, for callers that only read a field and must not pay for a clone.
|
|
425
|
+
*
|
|
426
|
+
* Includes `role: "tool"` messages now that a 后台回传 is a real queue item: same dock, same ordering,
|
|
427
|
+
* same delivery state machine, same steer/claim semantics - the only thing different is the render
|
|
428
|
+
* (kept as a 后台回传 block) and what the agent is told when its turn comes (see `composePrompt`). */
|
|
409
429
|
private waitingMessages;
|
|
410
430
|
private requireWaitingMessage;
|
|
411
431
|
private persistMessages;
|
|
@@ -475,12 +495,9 @@ export declare class Store {
|
|
|
475
495
|
*/
|
|
476
496
|
cancelWaitingMessage(conversationId: string, messageId: string): Promise<Message | undefined>;
|
|
477
497
|
/**
|
|
478
|
-
* Rewrites the words of one waiting message.
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
* changing it is changing the whole truth of the message - there is no second copy anywhere that could
|
|
482
|
-
* fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
|
|
483
|
-
* not one the user gets to rewrite after the fact.
|
|
498
|
+
* Rewrites the words of one waiting message. User sends only - a 后台回传 carries a `<task>` envelope
|
|
499
|
+
* the model parses for state, summary and body, and editing it would change a fact the agent has
|
|
500
|
+
* already started reasoning from. The client dock shows no 「改字」 button for `role: "tool"` rows.
|
|
484
501
|
*/
|
|
485
502
|
editWaitingMessage(conversationId: string, messageId: string, content: string): Promise<Message>;
|
|
486
503
|
/**
|