@evandu/super-pi 0.1.146 → 0.1.147
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 +60 -1
- package/agents/super-pi-task/index.ts +4 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +8 -8
- package/dist/client/assets/{CodeMirrorEditor-CEwBCVM6.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
- package/dist/client/assets/{dist-DC1IocrS.js → dist-1Innu17E.js} +1 -1
- package/dist/client/assets/{dist-CSCUT1PQ.js → dist-1lynFOc5.js} +1 -1
- package/dist/client/assets/{dist-Do52MRwm.js → dist-B1qLZVMK.js} +1 -1
- package/dist/client/assets/{dist-CAOcjKAQ.js → dist-B4pz_xdd.js} +1 -1
- package/dist/client/assets/{dist-B0RXZeXQ.js → dist-B6KwDpeI.js} +1 -1
- package/dist/client/assets/{dist-DFMtP3wG.js → dist-BJVI1Unv.js} +1 -1
- package/dist/client/assets/{dist-oLq6HXLG.js → dist-BWvzD8Pe.js} +1 -1
- package/dist/client/assets/{dist-9F1wL-T5.js → dist-BayWssd0.js} +1 -1
- package/dist/client/assets/{dist-DBUqwiWG.js → dist-Bk_pkBVH.js} +1 -1
- package/dist/client/assets/{dist-CbVf2Cjn.js → dist-BpeClAv0.js} +1 -1
- package/dist/client/assets/{dist-DftD4ibj.js → dist-BsmJg5PX.js} +1 -1
- package/dist/client/assets/{dist-CUB_Chdh.js → dist-Bx7s4rni.js} +1 -1
- package/dist/client/assets/{dist-Bh8Q7Y_n.js → dist-CDBKM9tL.js} +1 -1
- package/dist/client/assets/{dist-BoyICDlL.js → dist-CGzXCzCG.js} +1 -1
- package/dist/client/assets/{dist-BCNaDgNW.js → dist-CRxCwn-Z.js} +1 -1
- package/dist/client/assets/{dist-76ArZ5M4.js → dist-CTDgUnBG.js} +1 -1
- package/dist/client/assets/{dist-BXBSIuTD.js → dist-D1VfMWmf.js} +1 -1
- package/dist/client/assets/{dist-DN0GlnnZ.js → dist-DTZLNEci.js} +1 -1
- package/dist/client/assets/{dist-JvQIIdqB.js → dist-DuUi29km.js} +1 -1
- package/dist/client/assets/{dist-CHt8PX8R.js → dist-F3S0t1oM.js} +1 -1
- package/dist/client/assets/{dist-BuR5Iagd.js → dist-QZ-FAVJV.js} +1 -1
- package/dist/client/assets/{dist-Bx6ZWYUW.js → dist-u0nBg0fZ.js} +1 -1
- package/dist/client/assets/index-BMpqviwU.css +1 -0
- package/dist/client/assets/{index-E7yaLa2C.js → index-Dzyp_7sf.js} +43 -43
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +17 -0
- package/dist/server/active-memory.js +39 -0
- package/dist/server/application/conversation-service.js +6 -0
- package/dist/server/application/run-service.d.ts +16 -0
- package/dist/server/application/run-service.js +175 -26
- package/dist/server/application/session-orchestrator-service.d.ts +103 -1
- package/dist/server/application/session-orchestrator-service.js +303 -8
- package/dist/server/application/session-watchdog.d.ts +159 -0
- package/dist/server/application/session-watchdog.js +244 -0
- package/dist/server/http-server.js +136 -76
- package/dist/server/index.js +3 -0
- package/dist/server/knowledge-vectors.d.ts +15 -0
- package/dist/server/knowledge-vectors.js +118 -37
- package/dist/server/knowledge.d.ts +7 -8
- package/dist/server/knowledge.js +133 -55
- package/dist/server/length-continuation.d.ts +50 -0
- package/dist/server/length-continuation.js +55 -0
- package/dist/server/pi-session-events.js +5 -1
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +16 -0
- package/dist/server/runtime.js +2 -1
- package/dist/server/session-tree-scope.d.ts +125 -0
- package/dist/server/session-tree-scope.js +337 -0
- package/dist/server/store.d.ts +13 -0
- package/dist/server/store.js +37 -4
- package/dist/server/task-tool.d.ts +1 -1
- package/dist/server/task-tool.js +2 -1
- package/dist/server/turn-failure.d.ts +26 -0
- package/dist/server/turn-failure.js +51 -0
- package/dist/server/types.d.ts +15 -1
- package/package.json +19 -3
- package/dist/client/assets/index-D-OxlUxN.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-Dzyp_7sf.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BMpqviwU.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -77,6 +77,23 @@ export declare class ActiveMemory {
|
|
|
77
77
|
score: number;
|
|
78
78
|
content: string;
|
|
79
79
|
}[];
|
|
80
|
+
/**
|
|
81
|
+
* Rebuilds one conversation's postings after a rare destructive transcript mutation (revert/delete).
|
|
82
|
+
*
|
|
83
|
+
* WHY THIS EXISTS. The index is a CACHE of the store, and `removeFromIndex` is the surgical half of
|
|
84
|
+
* that contract: the caller tells it which ids LEFT, and exactly those postings go. This is the other
|
|
85
|
+
* half - it rebuilds what is there NOW. A destructive mutation can also change a message's CONTENT
|
|
86
|
+
* (a withdrawn turn replaced by a rewritten one), and a posting that still carries the old text
|
|
87
|
+
* answers a search with a turn the transcript no longer holds. So the rare path pays a full
|
|
88
|
+
* re-tokenize of ONE conversation, and the rest of the workspace stays indexed.
|
|
89
|
+
*
|
|
90
|
+
* THE REMOVAL LOOP WALKS EVERY WORKSPACE, because a posting remembers the conversation it came from:
|
|
91
|
+
* the conversation's terms must stop answering searches wherever they were indexed, not only in the
|
|
92
|
+
* workspace it lives in. The reverse index (`termsByMessage`) is pruned in the same loop, so the two
|
|
93
|
+
* halves of the index cannot disagree about which messages are indexed - a stale entry would point
|
|
94
|
+
* the surgical removal at terms that are no longer there.
|
|
95
|
+
*/
|
|
96
|
+
syncConversation(conversation: Conversation): void;
|
|
80
97
|
private ensureIndex;
|
|
81
98
|
/**
|
|
82
99
|
* Drops exactly the given messages from the search index, leaving the rest of the workspace indexed.
|
|
@@ -135,6 +135,41 @@ export class ActiveMemory {
|
|
|
135
135
|
void id, candidates.set(value.messageId, value);
|
|
136
136
|
return [...candidates.values()].filter(m => !sessionId || m.conversationId === sessionId).map(m => ({ ...m, score: words.reduce((n, word) => n + (m.content.toLocaleLowerCase().includes(word) ? 1 : 0), 0) })).filter(m => m.score > 0).sort((a, b) => b.score - a.score).slice(0, 6).map(m => ({ ...m, content: m.content.slice(0, 1200) }));
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Rebuilds one conversation's postings after a rare destructive transcript mutation (revert/delete).
|
|
140
|
+
*
|
|
141
|
+
* WHY THIS EXISTS. The index is a CACHE of the store, and `removeFromIndex` is the surgical half of
|
|
142
|
+
* that contract: the caller tells it which ids LEFT, and exactly those postings go. This is the other
|
|
143
|
+
* half - it rebuilds what is there NOW. A destructive mutation can also change a message's CONTENT
|
|
144
|
+
* (a withdrawn turn replaced by a rewritten one), and a posting that still carries the old text
|
|
145
|
+
* answers a search with a turn the transcript no longer holds. So the rare path pays a full
|
|
146
|
+
* re-tokenize of ONE conversation, and the rest of the workspace stays indexed.
|
|
147
|
+
*
|
|
148
|
+
* THE REMOVAL LOOP WALKS EVERY WORKSPACE, because a posting remembers the conversation it came from:
|
|
149
|
+
* the conversation's terms must stop answering searches wherever they were indexed, not only in the
|
|
150
|
+
* workspace it lives in. The reverse index (`termsByMessage`) is pruned in the same loop, so the two
|
|
151
|
+
* halves of the index cannot disagree about which messages are indexed - a stale entry would point
|
|
152
|
+
* the surgical removal at terms that are no longer there.
|
|
153
|
+
*/
|
|
154
|
+
syncConversation(conversation) {
|
|
155
|
+
for (const [, terms] of this.inverted) {
|
|
156
|
+
for (const [term, postings] of terms) {
|
|
157
|
+
for (const [messageId, value] of postings) {
|
|
158
|
+
if (value.conversationId === conversation.id) {
|
|
159
|
+
postings.delete(messageId);
|
|
160
|
+
this.indexedMessages.delete(messageId);
|
|
161
|
+
this.termsByMessage.delete(messageId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!postings.size)
|
|
165
|
+
terms.delete(term);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!this.indexedWorkspaces.has(conversation.workspaceId))
|
|
169
|
+
return;
|
|
170
|
+
for (const message of conversation.messages)
|
|
171
|
+
this.addToIndex(conversation, message);
|
|
172
|
+
}
|
|
138
173
|
ensureIndex(workspaceId) {
|
|
139
174
|
if (this.indexedWorkspaces.has(workspaceId))
|
|
140
175
|
return;
|
|
@@ -224,6 +259,10 @@ export class ActiveMemory {
|
|
|
224
259
|
terms.set(term, bucket = new Map());
|
|
225
260
|
bucket.set(message.id, value);
|
|
226
261
|
}
|
|
262
|
+
// THE MESSAGE IS IN THE INDEX NOW, SO THE SET MUST SAY SO. `completed()` walks this set to find
|
|
263
|
+
// the messages it has never indexed, and a message that is indexed without being marked here is
|
|
264
|
+
// re-tokenized on EVERY turn end - the whole-conversation cost this file exists to avoid.
|
|
265
|
+
this.indexedMessages.add(message.id);
|
|
227
266
|
}
|
|
228
267
|
async tool(token, body) {
|
|
229
268
|
const identity = this.identity(token);
|
|
@@ -435,6 +435,12 @@ export class ConversationService {
|
|
|
435
435
|
const withdrawn = conversation.messages.find(item => item.id === messageId);
|
|
436
436
|
const withdrawnContent = withdrawn ? { content: withdrawn.content, images: withdrawn.images } : undefined;
|
|
437
437
|
const applied = await this.store.revertMessages(conversationId, messageId, withdraw);
|
|
438
|
+
// THE RECALL INDEX MUST SEE THE TRUNCATED TRANSCRIPT. `conversation` above is the DETACHED COPY
|
|
439
|
+
// that `turnContext` took BEFORE the store spliced its live array, so it still holds the withdrawn
|
|
440
|
+
// turns - re-syncing from it would put the text the person just withdrew straight back into the
|
|
441
|
+
// index, which is the exact leak this path exists to close. The store's post-revert conversation is
|
|
442
|
+
// the truth, and this rare destructive path is where one clone of it is the right price.
|
|
443
|
+
this.memory?.syncConversation(this.store.conversation(conversationId) ?? conversation);
|
|
438
444
|
// THE MEMORY HALF OF THE REWIND, and the half that used to be missing. Everything above removes the
|
|
439
445
|
// turn from Pi's file and from state.json; the memory store is a second durable copy of what the turn
|
|
440
446
|
// said, and it is spliced into every later prompt. So the model kept being told about a turn the person
|
|
@@ -161,6 +161,15 @@ export declare class RunService {
|
|
|
161
161
|
resultPath: string;
|
|
162
162
|
error?: string;
|
|
163
163
|
}): Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* Dispatches a background result (exec or child session) into the conversation.
|
|
166
|
+
*
|
|
167
|
+
* IDLE: starts a fresh turn via `processWaitingMessage`.
|
|
168
|
+
* BUSY: attempts to steer the message into the running turn so it is delivered at the
|
|
169
|
+
* very next tool batch boundary rather than waiting for the entire turn to complete.
|
|
170
|
+
* INTERRUPTING: quietly leaves the message waiting; the interrupted or next turn will pick it up.
|
|
171
|
+
*/
|
|
172
|
+
private dispatchBackgroundResult;
|
|
164
173
|
/**
|
|
165
174
|
* Stops an active background task (spawned process or detached subagent) on user request.
|
|
166
175
|
*
|
|
@@ -171,6 +180,8 @@ export declare class RunService {
|
|
|
171
180
|
* updates immediately.
|
|
172
181
|
*/
|
|
173
182
|
stopBackgroundTask(taskId: string): Promise<void>;
|
|
183
|
+
hasActiveBackgroundTasks(conversationId: string): boolean;
|
|
184
|
+
settleBackgroundStatus(conversationId: string): Promise<void>;
|
|
174
185
|
/** Per-conversation command lane. All queue mutations and run claims use the same lane, so
|
|
175
186
|
* two browser tabs cannot both observe an idle conversation and start duplicate turns. */
|
|
176
187
|
private readonly conversationOps;
|
|
@@ -379,6 +390,11 @@ export declare class RunService {
|
|
|
379
390
|
* stream it - which is why the two cases are expressed identically and cannot drift apart.
|
|
380
391
|
*/
|
|
381
392
|
runConversationTurns(conversation: Conversation, workspace: Workspace, prompt: string, controller: AbortController, firstMessageId: string, initialImages?: MessageImage[]): Promise<void>;
|
|
393
|
+
/**
|
|
394
|
+
* Refuses a turn when the selected model cannot accept image input, either because the incoming
|
|
395
|
+
* message carries images or because previous messages in the conversation contain images.
|
|
396
|
+
*/
|
|
397
|
+
private assertModelSupportsImages;
|
|
382
398
|
/** Keep admission owned through final persistence and recheck the durable queue before releasing. */
|
|
383
399
|
private runUntilDrained;
|
|
384
400
|
/**
|
|
@@ -5,7 +5,8 @@ import { readAgents } from "../agents.js";
|
|
|
5
5
|
import { verifyCapability } from "../capability.js";
|
|
6
6
|
import { capText, validateWorkflowPlan, workflowSummary } from "../workflow.js";
|
|
7
7
|
import { ensureNameIndex, recordWorkspacePath } from "../file-name-index.js";
|
|
8
|
-
import { formatTurnFailure } from "../turn-failure.js";
|
|
8
|
+
import { formatTurnFailure, isLengthExceededError } from "../turn-failure.js";
|
|
9
|
+
import { isLengthContinuation, lengthContinuationText } from "../length-continuation.js";
|
|
9
10
|
import { describeThrownWithStack } from "../failure-text.js";
|
|
10
11
|
import { toWorkspaceRelative } from "../files.js";
|
|
11
12
|
import { refreshWorkspaceIndex } from "../file-index.js";
|
|
@@ -78,6 +79,12 @@ function composePrompt(message) {
|
|
|
78
79
|
if (message.role !== "tool")
|
|
79
80
|
return message.content;
|
|
80
81
|
const content = message.content;
|
|
82
|
+
// A CONTINUATION ORDER IS NOT AN ORDER FROM A PARENT AGENT. It is the system telling the session
|
|
83
|
+
// to finish an answer that was cut short, and the model has to hear THAT - "here is a task you
|
|
84
|
+
// were given" would make it start the work over rather than finish what it had already said.
|
|
85
|
+
if (isLengthContinuation(content)) {
|
|
86
|
+
return `(上一条回答因达到输出长度上限被截断,请接着写完)\n\n${lengthContinuationText(content)}`;
|
|
87
|
+
}
|
|
81
88
|
// A DISPATCHED ORDER IS NOT A DELIVERED RESULT. The two both travel as a `tool` message, and the
|
|
82
89
|
// envelope tells them apart: a result carries `<task ...>`, an order does not.
|
|
83
90
|
if (!/<task id="[^"]+" state="[^"]+"/.test(content)) {
|
|
@@ -312,20 +319,20 @@ export class RunService {
|
|
|
312
319
|
// "[后台任务 X 失败]" is part of the same "after the current answer finishes, the result is
|
|
313
320
|
// processed automatically" contract, and the auto-triggered turn goes through the same
|
|
314
321
|
// `composePrompt` path the success case does.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// goes into Pi's follow-up queue any more.
|
|
322
|
+
//
|
|
323
|
+
// BUSY: steer the result into the running turn so Pi drains it at the very next tool batch
|
|
324
|
+
// boundary instead of waiting for the whole turn to complete.
|
|
325
|
+
void this.dispatchBackgroundResult(task.conversationId, queued.id).catch(async (error) => {
|
|
326
|
+
console.warn(`Super Pi: dispatch background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
327
|
+
await this.settleBackgroundStatus(task.conversationId);
|
|
328
|
+
});
|
|
323
329
|
}
|
|
324
330
|
catch (error) {
|
|
325
331
|
console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
326
332
|
}
|
|
327
333
|
finally {
|
|
328
334
|
await this.store.removeBackgroundTask(sessionId);
|
|
335
|
+
await this.settleBackgroundStatus(task.conversationId);
|
|
329
336
|
this.broadcaster.broadcastState();
|
|
330
337
|
}
|
|
331
338
|
})());
|
|
@@ -371,13 +378,68 @@ export class RunService {
|
|
|
371
378
|
});
|
|
372
379
|
if (!queued)
|
|
373
380
|
return;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
});
|
|
378
|
-
}
|
|
381
|
+
void this.dispatchBackgroundResult(parentId, queued.id).catch((error) => {
|
|
382
|
+
console.warn(`Super Pi: dispatch child session result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
383
|
+
});
|
|
379
384
|
})());
|
|
380
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* Dispatches a background result (exec or child session) into the conversation.
|
|
388
|
+
*
|
|
389
|
+
* IDLE: starts a fresh turn via `processWaitingMessage`.
|
|
390
|
+
* BUSY: attempts to steer the message into the running turn so it is delivered at the
|
|
391
|
+
* very next tool batch boundary rather than waiting for the entire turn to complete.
|
|
392
|
+
* INTERRUPTING: quietly leaves the message waiting; the interrupted or next turn will pick it up.
|
|
393
|
+
*/
|
|
394
|
+
async dispatchBackgroundResult(conversationId, messageId) {
|
|
395
|
+
if (this.interruptTargets.has(conversationId))
|
|
396
|
+
return;
|
|
397
|
+
if (!this.activeRuns.has(conversationId)) {
|
|
398
|
+
await this.processWaitingMessage(conversationId, messageId);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Conversation is busy. Attempt to steer the background result directly into the active turn.
|
|
402
|
+
try {
|
|
403
|
+
// THE LANE IS RELEASED BEFORE THE FALLBACK. `processWaitingMessage` acquires this same lane,
|
|
404
|
+
// so calling it from inside the serialized callback re-enters the lane and deadlocks: the
|
|
405
|
+
// outer work waits for the inner one, and the inner one waits for the outer one.
|
|
406
|
+
const startWaiting = await this.serializeConversation(conversationId, async () => {
|
|
407
|
+
if (this.interruptTargets.has(conversationId))
|
|
408
|
+
return false;
|
|
409
|
+
// `processWaitingMessage` acquires this lane, so the fallback must run after serialization exits.
|
|
410
|
+
if (!this.activeRuns.has(conversationId))
|
|
411
|
+
return true;
|
|
412
|
+
if (!this.runtime.steer)
|
|
413
|
+
return false;
|
|
414
|
+
if (this.runtime.canSteer && !this.runtime.canSteer(conversationId))
|
|
415
|
+
return false;
|
|
416
|
+
const waiting = this.store.listWaitingMessages(conversationId);
|
|
417
|
+
const item = waiting.find((m) => m.id === messageId && m.delivery === "waiting");
|
|
418
|
+
if (!item)
|
|
419
|
+
return false;
|
|
420
|
+
await this.moveAheadOfUnclaimed(conversationId, messageId);
|
|
421
|
+
this.broadcaster.broadcastState();
|
|
422
|
+
const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
|
|
423
|
+
this.broadcaster.broadcastState();
|
|
424
|
+
try {
|
|
425
|
+
await this.runtime.steer(conversationId, composePrompt(claimed), claimed.images);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
await this.store.releaseWaitingMessage(conversationId, claimed.id);
|
|
429
|
+
this.broadcaster.broadcastState();
|
|
430
|
+
if (!(error instanceof SteerUnavailableError)) {
|
|
431
|
+
console.warn(`Super Pi: steer background result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
});
|
|
436
|
+
if (startWaiting)
|
|
437
|
+
await this.processWaitingMessage(conversationId, messageId);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
console.warn(`Super Pi: steer background result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
381
443
|
/**
|
|
382
444
|
* Stops an active background task (spawned process or detached subagent) on user request.
|
|
383
445
|
*
|
|
@@ -408,8 +470,29 @@ export class RunService {
|
|
|
408
470
|
}
|
|
409
471
|
}
|
|
410
472
|
await this.store.removeBackgroundTask(taskId);
|
|
473
|
+
if (task?.conversationId) {
|
|
474
|
+
await this.settleBackgroundStatus(task.conversationId);
|
|
475
|
+
}
|
|
411
476
|
this.broadcaster.broadcastState();
|
|
412
477
|
}
|
|
478
|
+
hasActiveBackgroundTasks(conversationId) {
|
|
479
|
+
const tasks = this.store.snapshot().backgroundTasks ?? [];
|
|
480
|
+
return tasks.some((t) => t.conversationId === conversationId && (t.status === "running" || !t.status));
|
|
481
|
+
}
|
|
482
|
+
async settleBackgroundStatus(conversationId) {
|
|
483
|
+
const conv = this.store.conversation(conversationId);
|
|
484
|
+
if (!conv || conv.status !== "awaiting_background")
|
|
485
|
+
return;
|
|
486
|
+
if (this.activeRuns.has(conversationId))
|
|
487
|
+
return;
|
|
488
|
+
if (this.hasActiveBackgroundTasks(conversationId))
|
|
489
|
+
return;
|
|
490
|
+
const hasWaiting = this.store.listWaitingMessages(conversationId).some(m => m.delivery === "waiting");
|
|
491
|
+
if (!hasWaiting) {
|
|
492
|
+
await this.store.setStatus(conversationId, "completed");
|
|
493
|
+
this.broadcaster.broadcastState();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
413
496
|
/** Per-conversation command lane. All queue mutations and run claims use the same lane, so
|
|
414
497
|
* two browser tabs cannot both observe an idle conversation and start duplicate turns. */
|
|
415
498
|
conversationOps = new Map();
|
|
@@ -689,6 +772,17 @@ export class RunService {
|
|
|
689
772
|
// the SDK's five-second backstop. Do this before any await so a stale stop cannot reach new work.
|
|
690
773
|
this.subagents.stopConversation(conversationId);
|
|
691
774
|
this.workflows.stopConversation(conversationId);
|
|
775
|
+
const bgTasks = (this.store.snapshot().backgroundTasks ?? []).filter(t => t.conversationId === conversationId);
|
|
776
|
+
for (const task of bgTasks) {
|
|
777
|
+
if (this.execService) {
|
|
778
|
+
try {
|
|
779
|
+
this.execService.kill(task.id);
|
|
780
|
+
}
|
|
781
|
+
catch { /* ignore if already exited */ }
|
|
782
|
+
}
|
|
783
|
+
this.subagents.stop(task.id);
|
|
784
|
+
await this.store.removeBackgroundTask(task.id);
|
|
785
|
+
}
|
|
692
786
|
// Cancel human-facing waits BEFORE asking Pi to become idle. A session can be blocked inside an
|
|
693
787
|
// extension UI bridge or the question tool; Pi's idle promise cannot settle until that wait is
|
|
694
788
|
// released, so cancelling after `runtime.stop()` turns a click into the full idle backstop.
|
|
@@ -1585,6 +1679,7 @@ export class RunService {
|
|
|
1585
1679
|
try {
|
|
1586
1680
|
const agent = await this.runtime.agentFor?.(conversation);
|
|
1587
1681
|
const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
|
|
1682
|
+
await this.assertModelSupportsImages(conversation, Boolean(initialImages?.length), firstMessageId);
|
|
1588
1683
|
const opened = await this.openTurn(conversation.id, firstMessageId);
|
|
1589
1684
|
await this.streamTurn(conversation, workspace, prompt, opened.assistant, controller, opened.user.id, initialImages, identity);
|
|
1590
1685
|
for (;;) {
|
|
@@ -1597,6 +1692,7 @@ export class RunService {
|
|
|
1597
1692
|
// answers them with a turn of their own, which is the behaviour there was before Pi's queue was
|
|
1598
1693
|
// used at all. Nothing is lost, and nothing is delivered twice.
|
|
1599
1694
|
await this.clearPiQueues(conversation.id);
|
|
1695
|
+
await this.store.releaseSendingMessages(conversation.id);
|
|
1600
1696
|
// Taken AND opened, in that order, by two separate writes. The take is what bounds this loop -
|
|
1601
1697
|
// every pass strictly shrinks the waiting set - and it is what makes a crash honest, because a
|
|
1602
1698
|
// server that dies between the two has a message that is neither waiting nor answered, and the
|
|
@@ -1604,24 +1700,49 @@ export class RunService {
|
|
|
1604
1700
|
const next = await this.store.takeNextWaitingMessage(conversation.id);
|
|
1605
1701
|
if (!next)
|
|
1606
1702
|
break;
|
|
1703
|
+
const current = this.turnContext(conversation.id).conversation;
|
|
1704
|
+
await this.assertModelSupportsImages(current, Boolean(next.images?.length), next.id);
|
|
1607
1705
|
const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
|
|
1608
1706
|
this.broadcaster.broadcastState();
|
|
1609
|
-
const
|
|
1707
|
+
const activeConversation = this.turnContext(conversation.id).conversation;
|
|
1610
1708
|
// `composePrompt` synthesises the prose for a 后台回传 (the `<task>` envelope is what the transcript
|
|
1611
1709
|
// column reads from; the prose is what leaves the server for Pi). For user messages it is a no-op.
|
|
1612
|
-
await this.streamTurn(
|
|
1710
|
+
await this.streamTurn(activeConversation, workspace, composePrompt(next), followUp.assistant, controller, followUp.user.id, next.images, identity);
|
|
1613
1711
|
}
|
|
1614
1712
|
}
|
|
1615
1713
|
finally {
|
|
1616
1714
|
await this.store.releaseSendingMessages(conversation.id);
|
|
1617
1715
|
}
|
|
1618
1716
|
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Refuses a turn when the selected model cannot accept image input, either because the incoming
|
|
1719
|
+
* message carries images or because previous messages in the conversation contain images.
|
|
1720
|
+
*/
|
|
1721
|
+
async assertModelSupportsImages(conversation, currentTurnHasImages, excludingMessageId) {
|
|
1722
|
+
const hasHistoryImages = conversation.messages.some((m) => m.id !== excludingMessageId && Boolean(m.images && m.images.length > 0));
|
|
1723
|
+
if (!currentTurnHasImages && !hasHistoryImages)
|
|
1724
|
+
return;
|
|
1725
|
+
const catalog = await this.runtime.catalog(conversation.model);
|
|
1726
|
+
const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
|
|
1727
|
+
?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
|
|
1728
|
+
if (currentModel && !currentModel.imageInput) {
|
|
1729
|
+
if (currentTurnHasImages) {
|
|
1730
|
+
throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
|
|
1731
|
+
}
|
|
1732
|
+
throw new Error(`会话历史包含图片,当前模型「${currentModel.label || currentModel.modelId}」不支持图片输入;请切回支持图文的模型继续对话`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1619
1735
|
/** Keep admission owned through final persistence and recheck the durable queue before releasing. */
|
|
1620
1736
|
async runUntilDrained(conversation, workspace, prompt, controller, messageId, images) {
|
|
1621
1737
|
for (;;) {
|
|
1622
1738
|
if (!controller.signal.aborted)
|
|
1623
1739
|
await this.runConversationTurns(conversation, workspace, prompt, controller, messageId, images);
|
|
1624
|
-
|
|
1740
|
+
// The `runConversationTurns` loop above drains every waiting message before returning, so a
|
|
1741
|
+
// turn that ends with `hasBg` true really is idle - it is waiting on a background task, not
|
|
1742
|
+
// about to start another turn.
|
|
1743
|
+
const hasBg = this.hasActiveBackgroundTasks(conversation.id);
|
|
1744
|
+
const finalStatus = controller.signal.aborted ? "stopped" : hasBg ? "awaiting_background" : "completed";
|
|
1745
|
+
await this.store.setStatus(conversation.id, finalStatus);
|
|
1625
1746
|
if (controller.signal.aborted)
|
|
1626
1747
|
return;
|
|
1627
1748
|
// No await between the last empty observation and releasing the slot.
|
|
@@ -1698,9 +1819,20 @@ export class RunService {
|
|
|
1698
1819
|
await this.store.setStatus(conversation.id, "stopped");
|
|
1699
1820
|
return;
|
|
1700
1821
|
}
|
|
1822
|
+
await this.assertModelSupportsImages(conversation, false, message.id);
|
|
1701
1823
|
await this.runUntilDrained(conversation, workspace, composePrompt(message), controller, message.id);
|
|
1702
1824
|
}
|
|
1703
1825
|
catch (error) {
|
|
1826
|
+
// A TRUNCATED ANSWER IS NOT A DEAD CHILD. The model filled its single-response output budget
|
|
1827
|
+
// and stopped mid-sentence; the work it was doing is not over. The conversation goes back to
|
|
1828
|
+
// `idle` - usable, not failed - and the truncation is recorded as a WARNING, so the orchestrator
|
|
1829
|
+
// above can see that the child needs to be told to carry on rather than reported as broken.
|
|
1830
|
+
if (isLengthExceededError(error)) {
|
|
1831
|
+
await this.store.setStatus(conversation.id, "idle");
|
|
1832
|
+
await this.store.setWarningReason(conversationId, formatTurnFailure(error));
|
|
1833
|
+
console.warn("Super Pi agent-dispatched turn was truncated by the output limit:", describeThrownWithStack(error));
|
|
1834
|
+
throw error;
|
|
1835
|
+
}
|
|
1704
1836
|
await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "failed");
|
|
1705
1837
|
if (controller.signal.aborted)
|
|
1706
1838
|
return;
|
|
@@ -1803,14 +1935,8 @@ export class RunService {
|
|
|
1803
1935
|
}
|
|
1804
1936
|
let expanded;
|
|
1805
1937
|
try {
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
|
|
1809
|
-
?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
|
|
1810
|
-
if (currentModel && !currentModel.imageInput) {
|
|
1811
|
-
throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1938
|
+
const currentConv = this.turnContext(conversationId).conversation;
|
|
1939
|
+
await this.assertModelSupportsImages(currentConv, Boolean(validImages?.length), message.id);
|
|
1814
1940
|
expanded = await expandAtReferences(this.store, workspace, trimmed);
|
|
1815
1941
|
}
|
|
1816
1942
|
catch (error) {
|
|
@@ -1877,6 +2003,25 @@ export class RunService {
|
|
|
1877
2003
|
if (error?.__superPiPreflight) {
|
|
1878
2004
|
throw error;
|
|
1879
2005
|
}
|
|
2006
|
+
// A TRUNCATED ANSWER IS NOT A FAILED TURN. The model filled its single-response output budget
|
|
2007
|
+
// and stopped mid-sentence: everything it said is on screen, and the conversation is still
|
|
2008
|
+
// usable - the reader's next move is to let it carry on, not to retry from scratch.
|
|
2009
|
+
//
|
|
2010
|
+
// So the conversation goes back to `idle` rather than `failed`, and the fact that the answer
|
|
2011
|
+
// was cut short is recorded as a WARNING. Marking it `failed` is what made an orchestrated
|
|
2012
|
+
// child session die: the orchestrator read the status, told the parent the task had failed,
|
|
2013
|
+
// and a task that was one turn from done was thrown away.
|
|
2014
|
+
//
|
|
2015
|
+
// AND THE TURN DOES NOT THROW. The reader's message WAS accepted and WAS answered - partially.
|
|
2016
|
+
// Throwing here would tell the browser the send failed, and the browser's response to a failed
|
|
2017
|
+
// send is to offer a retry, which is the wrong move: retrying asks for the same answer again
|
|
2018
|
+
// instead of asking for the rest of this one.
|
|
2019
|
+
if (isLengthExceededError(error)) {
|
|
2020
|
+
await this.store.setStatus(conversation.id, "idle");
|
|
2021
|
+
await this.store.setWarningReason(conversation.id, formatTurnFailure(error));
|
|
2022
|
+
console.warn("Super Pi turn was truncated by the output limit:", describeThrownWithStack(error));
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
1880
2025
|
await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "failed");
|
|
1881
2026
|
if (controller.signal.aborted)
|
|
1882
2027
|
return;
|
|
@@ -2014,6 +2159,7 @@ export class RunService {
|
|
|
2014
2159
|
const message = conversation.messages.find((item) => item.id === messageId);
|
|
2015
2160
|
if (!message)
|
|
2016
2161
|
throw new Error("消息不存在或已被撤回");
|
|
2162
|
+
await this.assertModelSupportsImages(conversation, Boolean(message.images?.length), message.id);
|
|
2017
2163
|
// runConversationTurns opens this first message; do not create a second assistant placeholder.
|
|
2018
2164
|
await this.runUntilDrained(conversation, workspace, composePrompt(message), controller, message.id, message.images);
|
|
2019
2165
|
}
|
|
@@ -2207,8 +2353,9 @@ export class RunService {
|
|
|
2207
2353
|
return row;
|
|
2208
2354
|
});
|
|
2209
2355
|
if (!signal?.aborted && run.status !== "stopped" && !this.activeRuns.has(conversation.id) && !this.interruptTargets.has(conversation.id)) {
|
|
2210
|
-
void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
|
|
2356
|
+
void this.processWaitingMessage(conversation.id, queued.id).catch(async (error) => {
|
|
2211
2357
|
console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
2358
|
+
await this.settleBackgroundStatus(conversation.id);
|
|
2212
2359
|
});
|
|
2213
2360
|
}
|
|
2214
2361
|
}
|
|
@@ -2216,6 +2363,7 @@ export class RunService {
|
|
|
2216
2363
|
console.warn(`Super Pi: sub-agent result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
2217
2364
|
}
|
|
2218
2365
|
await this.store.removeBackgroundTask(run.id);
|
|
2366
|
+
await this.settleBackgroundStatus(conversation.id);
|
|
2219
2367
|
this.broadcaster.broadcastState();
|
|
2220
2368
|
}).catch(async (error) => {
|
|
2221
2369
|
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>`;
|
|
@@ -2233,6 +2381,7 @@ export class RunService {
|
|
|
2233
2381
|
console.warn(`Super Pi: sub-agent failure could not be appended as a tool message: ${innerError instanceof Error ? innerError.message : "unknown error"}`);
|
|
2234
2382
|
}
|
|
2235
2383
|
await this.store.removeBackgroundTask(backgroundId);
|
|
2384
|
+
await this.settleBackgroundStatus(conversationId);
|
|
2236
2385
|
this.broadcaster.broadcastState();
|
|
2237
2386
|
console.warn(`Super Pi: background sub-agent dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
2238
2387
|
}));
|
|
@@ -4,8 +4,18 @@ import type { RunService } from "./run-service.js";
|
|
|
4
4
|
import type { SessionLedger } from "../session-ledger.js";
|
|
5
5
|
import type { EventBroadcaster } from "../server-context.js";
|
|
6
6
|
import type { RunStatus } from "../types.js";
|
|
7
|
+
import { SessionWatchdog } from "./session-watchdog.js";
|
|
7
8
|
export declare const SESSION_OUTPUT_CHAR_LIMIT: number;
|
|
8
9
|
export declare const sessionResultRelativePath: (conversationId: string) => string;
|
|
10
|
+
/**
|
|
11
|
+
* HOW MANY TIMES A CHILD MAY BE TOLD TO CONTINUE AFTER ITS OUTPUT WAS TRUNCATED.
|
|
12
|
+
*
|
|
13
|
+
* The bound is not about tokens - it is about a model that truncates on EVERY response. Three
|
|
14
|
+
* continuations cover the ordinary case (a long answer split across a few responses) and stop the
|
|
15
|
+
* pathological one. Past the bound the child is reported as unfinished, which is a true sentence
|
|
16
|
+
* about what happened, instead of a loop that never ends.
|
|
17
|
+
*/
|
|
18
|
+
export declare const MAX_LENGTH_CONTINUATIONS = 3;
|
|
9
19
|
export interface SpawnSessionOptions {
|
|
10
20
|
/** The high-level task to be performed by the sibling session. */
|
|
11
21
|
task: string;
|
|
@@ -33,6 +43,18 @@ export interface SpawnSessionOptions {
|
|
|
33
43
|
initialPrompt?: string;
|
|
34
44
|
/** Whether to await turn completion before returning (default: false, asynchronous dispatch). */
|
|
35
45
|
wait?: boolean;
|
|
46
|
+
/** Optional silence watchdog timeout in seconds. If set, parent is reminded if child goes quiet for this duration. */
|
|
47
|
+
watchdogTimeoutSeconds?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface ReportSessionOptions {
|
|
50
|
+
/** The report content (milestone, findings, roadblock, or final output). */
|
|
51
|
+
content: string;
|
|
52
|
+
/**
|
|
53
|
+
* The status of the child session when reporting.
|
|
54
|
+
* "in_progress" (default): interim update; resets the watchdog timer.
|
|
55
|
+
* "completed" | "failed": terminal update; cancels the watchdog timer.
|
|
56
|
+
*/
|
|
57
|
+
status?: "in_progress" | "completed" | "failed";
|
|
36
58
|
}
|
|
37
59
|
export interface StopSessionOptions {
|
|
38
60
|
/** Stop mode: "active" (active turn), "queued" (waiting messages in queue), or "all" (both, default). */
|
|
@@ -77,7 +99,35 @@ export declare class SessionOrchestratorService {
|
|
|
77
99
|
private readonly runService;
|
|
78
100
|
private readonly ledger;
|
|
79
101
|
private readonly broadcaster?;
|
|
80
|
-
|
|
102
|
+
readonly watchdog: SessionWatchdog;
|
|
103
|
+
/**
|
|
104
|
+
* HOW MANY TIMES EACH CHILD HAS BEEN TOLD TO CONTINUE AFTER A TRUNCATION.
|
|
105
|
+
*
|
|
106
|
+
* Per child rather than per dispatch, so a child that is given several tasks cannot extend itself
|
|
107
|
+
* indefinitely by being handed a new task each time it runs out of output budget. The entry is
|
|
108
|
+
* dropped as soon as the child finishes, so a child that continues successfully starts fresh on
|
|
109
|
+
* its next task.
|
|
110
|
+
*/
|
|
111
|
+
private readonly lengthContinuations;
|
|
112
|
+
/**
|
|
113
|
+
* THE CHILDREN THAT HAVE ALREADY REPORTED THEIR OWN RESULT TO THEIR PARENT.
|
|
114
|
+
*
|
|
115
|
+
* A child can report explicitly - the `report` action - and that report is the child's own
|
|
116
|
+
* statement of what it did. When the child's turn then ends, the automatic completion report
|
|
117
|
+
* would deliver the same thing a second time, so the automatic path steps aside for a child that
|
|
118
|
+
* is in this set.
|
|
119
|
+
*
|
|
120
|
+
* The entry is added only by a TERMINAL report (`completed` / `failed`), because an interim
|
|
121
|
+
* update is not the child's final word: the task is still running, and the parent will want to
|
|
122
|
+
* hear how it ended.
|
|
123
|
+
*/
|
|
124
|
+
private readonly reportedChildren;
|
|
125
|
+
constructor(store: Store, conversationService: ConversationService, runService: RunService, ledger: SessionLedger, broadcaster?: EventBroadcaster | undefined, watchdog?: SessionWatchdog);
|
|
126
|
+
/**
|
|
127
|
+
* The watchdog timer fired: the child has been silent for longer than the parent declared.
|
|
128
|
+
* Delivers a reminder message to the parent session. Does NOT interrupt or abort the child session.
|
|
129
|
+
*/
|
|
130
|
+
private handleWatchdogTimeout;
|
|
81
131
|
/**
|
|
82
132
|
* Spawns a new sibling conversation and immediately dispatches its initial task.
|
|
83
133
|
*/
|
|
@@ -93,6 +143,58 @@ export declare class SessionOrchestratorService {
|
|
|
93
143
|
}): Promise<{
|
|
94
144
|
conversationId: string;
|
|
95
145
|
}>;
|
|
146
|
+
/**
|
|
147
|
+
* THE CHILD'S OUTPUT WAS CUT OFF. TELL IT TO CARRY ON, AND LET IT.
|
|
148
|
+
*
|
|
149
|
+
* This is the recovery for a length stop, and it exists because a truncated answer is not a
|
|
150
|
+
* finished one. The child has already said most of what it had to say - that text is on its
|
|
151
|
+
* transcript - so the work left to do is the tail of the answer, not the whole of it.
|
|
152
|
+
*
|
|
153
|
+
* WHY THE CONTINUATION IS A NEW TURN RATHER THAN A RESUME OF THE SAME ONE. Pi has already settled
|
|
154
|
+
* the turn that was truncated: the model stopped, the message was written, the run ended. There is
|
|
155
|
+
* no live turn to resume, so the only way to get more output is to ask again - and asking again in
|
|
156
|
+
* the SAME conversation is what makes the continuation work, because the child's own partial
|
|
157
|
+
* answer is in its context and the model can see exactly where it stopped.
|
|
158
|
+
*
|
|
159
|
+
* WHY IT IS AN AGENT-SENT ORDER RATHER THAN A USER MESSAGE. The person never typed this, and the
|
|
160
|
+
* child's transcript must not show words under their name that they never said - the same reason
|
|
161
|
+
* `dispatch` uses `enqueueAndRunAsAgent` in the first place.
|
|
162
|
+
*
|
|
163
|
+
* Returns true when the child was continued (and the caller must NOT report a failure), and false
|
|
164
|
+
* when the continuation could not be made or the bound was reached - in which case the caller
|
|
165
|
+
* reports the original failure, which is then the honest description of what happened.
|
|
166
|
+
*/
|
|
167
|
+
private continueAfterLengthStop;
|
|
168
|
+
/**
|
|
169
|
+
* Explicit report from a child session to its direct parent.
|
|
170
|
+
* Can be called multiple times at any point during execution.
|
|
171
|
+
*/
|
|
172
|
+
report(childConversationId: string, options: ReportSessionOptions): Promise<{
|
|
173
|
+
delivered: boolean;
|
|
174
|
+
parentConversationId: string;
|
|
175
|
+
}>;
|
|
176
|
+
/**
|
|
177
|
+
* Sets or updates a silence watchdog timer on a child session.
|
|
178
|
+
*/
|
|
179
|
+
setWatchdog(options: {
|
|
180
|
+
childConversationId: string;
|
|
181
|
+
parentConversationId: string;
|
|
182
|
+
timeoutSeconds: number;
|
|
183
|
+
}): Promise<{
|
|
184
|
+
ok: boolean;
|
|
185
|
+
childConversationId: string;
|
|
186
|
+
timeoutSeconds: number;
|
|
187
|
+
}>;
|
|
188
|
+
/**
|
|
189
|
+
* Cancels a silence watchdog timer on a child session.
|
|
190
|
+
*/
|
|
191
|
+
cancelWatchdog(childConversationId: string): Promise<{
|
|
192
|
+
ok: boolean;
|
|
193
|
+
}>;
|
|
194
|
+
/**
|
|
195
|
+
* Re-arms persisted watchdogs from disk.
|
|
196
|
+
*/
|
|
197
|
+
restoreWatchdogs(): Promise<void>;
|
|
96
198
|
private persistSessionResult;
|
|
97
199
|
/**
|
|
98
200
|
* Returns a high-level summary of a session's progress without returning full transcripts.
|