@evandu/super-pi 0.1.133 → 0.1.136
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 +6 -6
- package/dist/client/assets/{CodeMirrorEditor-CZZfGD0U.js → CodeMirrorEditor-CWN8JIx4.js} +2 -2
- package/dist/client/assets/{dist-D4x1XTQb.js → dist-0vYGgCC5.js} +1 -1
- package/dist/client/assets/{dist-CFPXj4ws.js → dist-B0vHKLg1.js} +1 -1
- package/dist/client/assets/{dist-4Pts9xPO.js → dist-BA6BeFaf.js} +1 -1
- package/dist/client/assets/{dist-BtTFCNVQ.js → dist-BQjtlfpY.js} +1 -1
- package/dist/client/assets/{dist-CUKaDTyO.js → dist-BSOBu3lQ.js} +1 -1
- package/dist/client/assets/{dist-CYD2SqmG.js → dist-BtNKV30Z.js} +1 -1
- package/dist/client/assets/{dist-DHqqpxo4.js → dist-CTXz1R9C.js} +1 -1
- package/dist/client/assets/{dist-BffE00vI.js → dist-CV9QN-qU.js} +1 -1
- package/dist/client/assets/{dist-azDN8a65.js → dist-CYLUz40X.js} +1 -1
- package/dist/client/assets/{dist-aL2NGji_.js → dist-CwNZuQ4i.js} +1 -1
- package/dist/client/assets/{dist-CApAttfj.js → dist-DCuAT0n-.js} +1 -1
- package/dist/client/assets/{dist-DOYWHMIq.js → dist-DErmxG4Q.js} +1 -1
- package/dist/client/assets/{dist-DQf5nNxa.js → dist-DHTyhm7w.js} +1 -1
- package/dist/client/assets/{dist-fYp8Xwrx.js → dist-DMDxxHpj.js} +1 -1
- package/dist/client/assets/{dist-B5ruVtUG.js → dist-Dalb5yHq.js} +1 -1
- package/dist/client/assets/{dist-0FH-oFco.js → dist-OKarkelj.js} +1 -1
- package/dist/client/assets/{dist-DbbBgSwL.js → dist-Qc0xt1y7.js} +1 -1
- package/dist/client/assets/{dist-CxsunljU.js → dist-WkcafMsD.js} +1 -1
- package/dist/client/assets/{dist-CE5BnwJa.js → dist-ZvUavnTX.js} +1 -1
- package/dist/client/assets/{dist-BJinarhP.js → dist-q1T0uV1c.js} +1 -1
- package/dist/client/assets/{dist-C2nuBCP4.js → dist-t0oHfWqq.js} +1 -1
- package/dist/client/assets/{dist-LFfokYJs.js → dist-yOveRczL.js} +1 -1
- package/dist/client/assets/{index-YEN4ysHF.js → index-oDRgSKPX.js} +37 -37
- package/dist/client/assets/index-r-L_XCYs.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +18 -2
- package/dist/server/active-memory.js +73 -8
- package/dist/server/application/conversation-service.js +4 -2
- package/dist/server/application/run-service.d.ts +4 -0
- package/dist/server/application/run-service.js +114 -93
- package/dist/server/application/scheduled-task-service.d.ts +7 -0
- package/dist/server/application/scheduled-task-service.js +14 -5
- package/dist/server/application/workspace-service.d.ts +8 -4
- package/dist/server/application/workspace-service.js +11 -10
- package/dist/server/failure-text.d.ts +18 -0
- package/dist/server/failure-text.js +44 -0
- package/dist/server/files.js +11 -1
- package/dist/server/http-server.js +16 -5
- package/dist/server/index.js +5 -2
- package/dist/server/knowledge-embedding-worker.d.ts +1 -0
- package/dist/server/knowledge-embedding-worker.js +27 -0
- package/dist/server/knowledge-vectors.d.ts +5 -3
- package/dist/server/knowledge-vectors.js +49 -47
- package/dist/server/knowledge.d.ts +1 -0
- package/dist/server/knowledge.js +6 -2
- package/dist/server/model-filter.d.ts +13 -3
- package/dist/server/model-filter.js +7 -5
- package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
- package/dist/server/persistence/sqlite-state-repository.js +37 -1
- package/dist/server/providers.d.ts +13 -0
- package/dist/server/providers.js +27 -2
- package/dist/server/runtime.js +46 -0
- package/dist/server/store.d.ts +83 -0
- package/dist/server/store.js +140 -14
- package/dist/server/subagents.js +10 -2
- package/dist/server/types.d.ts +10 -0
- package/dist/server/ws-server.d.ts +3 -0
- package/dist/server/ws-server.js +35 -6
- package/package.json +21 -2
- package/dist/client/assets/index-D3MEG8uH.css +0 -1
|
@@ -9,6 +9,13 @@ export class ActiveMemory {
|
|
|
9
9
|
inverted = new Map();
|
|
10
10
|
indexedWorkspaces = new Set();
|
|
11
11
|
indexedMessages = new Set();
|
|
12
|
+
/**
|
|
13
|
+
* messageId -> which workspace's index holds it and under which terms. This is the reverse side of
|
|
14
|
+
* `inverted`, and it is what makes removal surgical: a revert knows exactly which messages it cut
|
|
15
|
+
* (the store's `plan.removedMessageIds`), so exactly those postings can be dropped - the rest of the
|
|
16
|
+
* workspace stays indexed instead of paying a full rebuild on the next search.
|
|
17
|
+
*/
|
|
18
|
+
termsByMessage = new Map();
|
|
12
19
|
notes;
|
|
13
20
|
capabilities = new Map();
|
|
14
21
|
reviews = new Map();
|
|
@@ -109,10 +116,14 @@ export class ActiveMemory {
|
|
|
109
116
|
release(token) { this.capabilities.delete(token); }
|
|
110
117
|
identity(token) {
|
|
111
118
|
const identity = this.capabilities.get(token);
|
|
112
|
-
if (!identity || !this.
|
|
119
|
+
if (!identity || !this.hasWorkspace(identity.workspaceId))
|
|
113
120
|
throw new Error("记忆授权已失效");
|
|
114
121
|
return identity;
|
|
115
122
|
}
|
|
123
|
+
hasWorkspace(workspaceId) {
|
|
124
|
+
const store = this.store;
|
|
125
|
+
return store.hasWorkspace ? store.hasWorkspace(workspaceId) : store.snapshot().workspaces.some(w => w.id === workspaceId);
|
|
126
|
+
}
|
|
116
127
|
search(workspaceId, query, sessionId) {
|
|
117
128
|
const words = query.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu)?.slice(0, 12) ?? [];
|
|
118
129
|
if (!words.length)
|
|
@@ -134,7 +145,9 @@ export class ActiveMemory {
|
|
|
134
145
|
continue;
|
|
135
146
|
const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
|
|
136
147
|
this.indexedMessages.add(message.id);
|
|
137
|
-
|
|
148
|
+
const terms = message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? [];
|
|
149
|
+
this.termsByMessage.set(message.id, { workspaceId, terms });
|
|
150
|
+
for (const word of terms) {
|
|
138
151
|
let bucket = index.get(word);
|
|
139
152
|
if (!bucket)
|
|
140
153
|
index.set(word, bucket = new Map());
|
|
@@ -145,7 +158,7 @@ export class ActiveMemory {
|
|
|
145
158
|
this.indexedWorkspaces.add(workspaceId);
|
|
146
159
|
}
|
|
147
160
|
/**
|
|
148
|
-
* Drops
|
|
161
|
+
* Drops exactly the given messages from the search index, leaving the rest of the workspace indexed.
|
|
149
162
|
*
|
|
150
163
|
* WHY THIS EXISTS. `ensureIndex` builds once per workspace and `addToIndex` only ever adds, so the
|
|
151
164
|
* index outlives the messages it indexed: a revert removes messages from the store while the index
|
|
@@ -154,17 +167,54 @@ export class ActiveMemory {
|
|
|
154
167
|
* history" after a revert every file-level assertion proved had really truncated. The store is the
|
|
155
168
|
* source of truth; the index is a cache of it, and a cache that survives an event that invalidated
|
|
156
169
|
* its source is wrong by definition.
|
|
170
|
+
*
|
|
171
|
+
* WHY SURGICAL. The caller (revert) knows precisely which message ids the store dropped, and this
|
|
172
|
+
* method trusts that list rather than re-deriving it: the ids not in `termsByMessage` were never
|
|
173
|
+
* indexed (no search had built the index, or a role the index skips), so a no-op for them is
|
|
174
|
+
* correct, and the next search - if the index was never built - builds from the post-revert store
|
|
175
|
+
* anyway. Dropping the whole workspace index instead would force the next search to rebuild every
|
|
176
|
+
* surviving conversation's entries for no reason, and the recall gap in between would answer
|
|
177
|
+
* searches with nothing at all.
|
|
157
178
|
*/
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
179
|
+
removeFromIndex(messageIds) {
|
|
180
|
+
for (const messageId of messageIds) {
|
|
181
|
+
const entry = this.termsByMessage.get(messageId);
|
|
182
|
+
if (!entry)
|
|
183
|
+
continue;
|
|
184
|
+
const terms = this.inverted.get(entry.workspaceId);
|
|
185
|
+
if (terms) {
|
|
186
|
+
for (const term of entry.terms) {
|
|
187
|
+
const bucket = terms.get(term);
|
|
188
|
+
if (!bucket)
|
|
189
|
+
continue;
|
|
190
|
+
bucket.delete(messageId);
|
|
191
|
+
if (!bucket.size)
|
|
192
|
+
terms.delete(term);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
this.termsByMessage.delete(messageId);
|
|
196
|
+
this.indexedMessages.delete(messageId);
|
|
197
|
+
}
|
|
161
198
|
}
|
|
162
199
|
addToIndex(conversation, message) {
|
|
163
200
|
if (!this.indexedWorkspaces.has(conversation.workspaceId) || (message.role !== "user" && message.role !== "assistant"))
|
|
164
201
|
return;
|
|
165
202
|
let terms = this.inverted.get(conversation.workspaceId);
|
|
166
203
|
const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
|
|
167
|
-
|
|
204
|
+
const messageTerms = message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? [];
|
|
205
|
+
// A REFRESH MUST ALSO FORGET THE OLD TERMS. The buckets map a term to the message that contains
|
|
206
|
+
// it, and a term that only the OLD content had would keep pointing at this message with a stale
|
|
207
|
+
// copy - so a search for a word the model has since dropped would still hand the reader the old
|
|
208
|
+
// text. The terms this message had are remembered exactly so they can be dropped here.
|
|
209
|
+
const previous = this.termsByMessage.get(message.id);
|
|
210
|
+
if (previous)
|
|
211
|
+
for (const term of previous.terms) {
|
|
212
|
+
const bucket = terms.get(term);
|
|
213
|
+
if (bucket)
|
|
214
|
+
bucket.delete(message.id);
|
|
215
|
+
}
|
|
216
|
+
this.termsByMessage.set(message.id, { workspaceId: conversation.workspaceId, terms: messageTerms });
|
|
217
|
+
for (const term of messageTerms) {
|
|
168
218
|
let bucket = terms.get(term);
|
|
169
219
|
if (!bucket)
|
|
170
220
|
terms.set(term, bucket = new Map());
|
|
@@ -232,7 +282,7 @@ export class ActiveMemory {
|
|
|
232
282
|
return alreadyLocked ? work() : this.locked(work);
|
|
233
283
|
}
|
|
234
284
|
async manage(workspaceId, body) {
|
|
235
|
-
if (!this.
|
|
285
|
+
if (!this.hasWorkspace(workspaceId))
|
|
236
286
|
throw new Error("工作区不存在");
|
|
237
287
|
if (body.action === "configure") {
|
|
238
288
|
await this.configure(body.settings);
|
|
@@ -266,6 +316,21 @@ export class ActiveMemory {
|
|
|
266
316
|
this.cancel(id); }
|
|
267
317
|
async waitForIdle() { await Promise.allSettled([...this.workspaceReviews.values()]); await this.serial; }
|
|
268
318
|
async completed(conversation) {
|
|
319
|
+
// REFRESH THIS TURN'S MESSAGES, DO NOT JUST FILL IN THE UNSEEN ONES.
|
|
320
|
+
//
|
|
321
|
+
// The index is a CACHE of the store's content, and an assistant message's content changes while
|
|
322
|
+
// the model is still writing it: the entry was built from the empty placeholder the turn opened
|
|
323
|
+
// with, so it holds the answer BEFORE the answer. "I have seen this message" is not "I have seen
|
|
324
|
+
// its final content" - treating the two as the same froze this turn's answer in the index, and a
|
|
325
|
+
// frozen answer is an answer that can never be recalled (measured by
|
|
326
|
+
// scripts/active-memory-index-content.test.mjs: the recall came back `undefined`).
|
|
327
|
+
//
|
|
328
|
+
// Only THIS TURN's messages are refreshed - the ones after the last user message. The rest of the
|
|
329
|
+
// transcript was indexed when it was final, and re-tokenizing a whole conversation on every turn
|
|
330
|
+
// end is exactly the cost this file is about.
|
|
331
|
+
const lastUserIndex = conversation.messages.map((message) => message.role).lastIndexOf("user");
|
|
332
|
+
for (const message of lastUserIndex >= 0 ? conversation.messages.slice(lastUserIndex + 1) : conversation.messages)
|
|
333
|
+
this.addToIndex(conversation, message);
|
|
269
334
|
for (const message of conversation.messages)
|
|
270
335
|
if (!this.indexedMessages.has(message.id))
|
|
271
336
|
this.addToIndex(conversation, message);
|
|
@@ -359,8 +359,10 @@ export class ConversationService {
|
|
|
359
359
|
// The recall index is a cache of the store's messages, and the store just lost several: left alone it
|
|
360
360
|
// keeps answering the memory extension's searches with the withdrawn turns, so the model is told about
|
|
361
361
|
// history the person reverted away even though the transcript, Pi's file and the memory store all
|
|
362
|
-
// agree it is gone.
|
|
363
|
-
this
|
|
362
|
+
// agree it is gone. The store's plan says exactly which message ids were dropped, so exactly those
|
|
363
|
+
// postings leave the index - the surviving conversations of this workspace stay indexed and the next
|
|
364
|
+
// search pays no rebuild.
|
|
365
|
+
this.memory?.removeFromIndex(applied.removedMessageIds);
|
|
364
366
|
console.log(`Super Pi: reverted conversation ${conversationId} to ${applied.boundaryIndex + 1} message(s): ` +
|
|
365
367
|
`${plan.removedMessageIds.length} message(s) and ${applied.doomedRunIds.length} sub-agent run(s) dropped from state.json; ` +
|
|
366
368
|
`Pi's file ${outcome.sessionFile} kept ${outcome.linesKept} line(s) and lost ${outcome.entriesDropped}; ` +
|
|
@@ -61,6 +61,7 @@ export declare class RunService {
|
|
|
61
61
|
private readonly extensionUiService;
|
|
62
62
|
private agentsDir;
|
|
63
63
|
private readonly workflows;
|
|
64
|
+
private readonly questionWaiters;
|
|
64
65
|
private memory?;
|
|
65
66
|
setMemory(memory: import("../active-memory.js").ActiveMemory): void;
|
|
66
67
|
/**
|
|
@@ -178,6 +179,9 @@ export declare class RunService {
|
|
|
178
179
|
answers?: QuestionAnswer[];
|
|
179
180
|
cancelled?: boolean;
|
|
180
181
|
}): Promise<void>;
|
|
182
|
+
private questionKey;
|
|
183
|
+
private answerPayload;
|
|
184
|
+
private settleQuestion;
|
|
181
185
|
/**
|
|
182
186
|
* THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
|
|
183
187
|
*
|
|
@@ -6,6 +6,7 @@ import { verifyCapability } from "../capability.js";
|
|
|
6
6
|
import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
|
|
7
7
|
import { ensureNameIndex } from "../file-name-index.js";
|
|
8
8
|
import { formatTurnFailure } from "../turn-failure.js";
|
|
9
|
+
import { describeThrownWithStack } from "../failure-text.js";
|
|
9
10
|
import { execFile } from "node:child_process";
|
|
10
11
|
import { promisify } from "node:util";
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -84,6 +85,7 @@ export class RunService {
|
|
|
84
85
|
extensionUiService;
|
|
85
86
|
agentsDir;
|
|
86
87
|
workflows;
|
|
88
|
+
questionWaiters = new Map();
|
|
87
89
|
memory;
|
|
88
90
|
setMemory(memory) { this.memory = memory; }
|
|
89
91
|
/**
|
|
@@ -238,52 +240,32 @@ export class RunService {
|
|
|
238
240
|
this.broadcaster.broadcastState();
|
|
239
241
|
return row;
|
|
240
242
|
});
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (custom) {
|
|
261
|
-
try {
|
|
262
|
-
await custom.call(this.runtime, task.conversationId, {
|
|
263
|
-
customType: "super-pi-task-result",
|
|
264
|
-
content: body,
|
|
265
|
-
display: "后台任务结果",
|
|
266
|
-
details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
|
|
267
|
-
});
|
|
268
|
-
deliveredViaCustom = true;
|
|
269
|
-
}
|
|
270
|
-
catch (error) {
|
|
271
|
-
console.warn(`Super Pi: custom delivery of the exec result failed, the queue path takes over: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (deliveredViaCustom) {
|
|
275
|
-
await this.store.takeWaitingMessage(task.conversationId, queued.id);
|
|
276
|
-
}
|
|
277
|
-
else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
243
|
+
// THE RESULT WAITS IN SUPER PI'S OWN QUEUE, AND THE QUEUE IS DRAINED ONE TURN AT A TIME.
|
|
244
|
+
//
|
|
245
|
+
// It used to go to Pi's own follow-up queue, and Pi DRAINS that queue all at once when a turn
|
|
246
|
+
// ends - the pending messages are collected into one array and fed to the model as a single
|
|
247
|
+
// turn. So two background results that finished close together were handed over TOGETHER, and
|
|
248
|
+
// the agent answered both at once (ledger TEST-033). That path also had a second defect of its
|
|
249
|
+
// own: Pi's queue knows nothing about the `waiting` row queued above, so delivering through it
|
|
250
|
+
// while leaving the row waiting gave the model the SAME result twice (ledger TEST-011).
|
|
251
|
+
//
|
|
252
|
+
// So the result is not delivered through that channel at all. It stays a `waiting` row, and
|
|
253
|
+
// Super Pi's own loop takes waiting messages ONE PER TURN - which is what that loop was built
|
|
254
|
+
// for, and what the sub-agent result path has always done.
|
|
255
|
+
//
|
|
256
|
+
// IDLE: a turn of its own starts now, so the model processes the result without the user
|
|
257
|
+
// having to send anything. The failure case is included on purpose: the model seeing
|
|
258
|
+
// "[后台任务 X 失败]" is part of the same "after the current answer finishes, the result is
|
|
259
|
+
// processed automatically" contract, and the auto-triggered turn goes through the same
|
|
260
|
+
// `composePrompt` path the success case does.
|
|
261
|
+
if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
278
262
|
void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
|
|
279
263
|
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
280
264
|
});
|
|
281
265
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
await this.submitToPiQueue(task.conversationId, queued);
|
|
286
|
-
}
|
|
266
|
+
// BUSY: the row stays in the waiting queue, and the turn that is running takes it when that
|
|
267
|
+
// turn ends - one message per turn, in the queue's own order. Nothing of a background result
|
|
268
|
+
// goes into Pi's follow-up queue any more.
|
|
287
269
|
}
|
|
288
270
|
catch (error) {
|
|
289
271
|
console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -374,7 +356,7 @@ export class RunService {
|
|
|
374
356
|
* other tool update uses - and the client can only get that index from the row it is already rendering.
|
|
375
357
|
*/
|
|
376
358
|
async answerQuestion(command) {
|
|
377
|
-
const existing = this.store.
|
|
359
|
+
const existing = this.store.question(command.conversationId, command.messageId, command.callIndex);
|
|
378
360
|
if (!existing)
|
|
379
361
|
throw new Error("这个工具调用不是一个问题");
|
|
380
362
|
const calls = await this.store.updateToolCall(command.conversationId, command.messageId, command.callIndex, {
|
|
@@ -385,6 +367,30 @@ export class RunService {
|
|
|
385
367
|
},
|
|
386
368
|
});
|
|
387
369
|
this.broadcaster.broadcast({ type: "message.tools", conversationId: command.conversationId, messageId: command.messageId, toolCalls: calls });
|
|
370
|
+
this.settleQuestion(command.conversationId, command.messageId, command.callIndex, calls[command.callIndex]?.question);
|
|
371
|
+
}
|
|
372
|
+
questionKey(conversationId, messageId, index) {
|
|
373
|
+
return `${conversationId}\0${messageId}\0${index}`;
|
|
374
|
+
}
|
|
375
|
+
answerPayload(current) {
|
|
376
|
+
const answers = (current.answers ?? []).map((answer) => {
|
|
377
|
+
const asked = current.items.find((item) => item.id === answer.id);
|
|
378
|
+
return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
|
|
379
|
+
});
|
|
380
|
+
return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
|
|
381
|
+
}
|
|
382
|
+
settleQuestion(conversationId, messageId, index, question) {
|
|
383
|
+
if (!question || question.status === "pending")
|
|
384
|
+
return;
|
|
385
|
+
const key = this.questionKey(conversationId, messageId, index);
|
|
386
|
+
const waiter = this.questionWaiters.get(key);
|
|
387
|
+
if (!waiter)
|
|
388
|
+
return;
|
|
389
|
+
this.questionWaiters.delete(key);
|
|
390
|
+
if (question.status === "answered")
|
|
391
|
+
waiter.resolve(this.answerPayload(question));
|
|
392
|
+
else
|
|
393
|
+
waiter.reject(new Error("用户没有回答这个问题"));
|
|
388
394
|
}
|
|
389
395
|
/**
|
|
390
396
|
* THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
|
|
@@ -414,28 +420,13 @@ export class RunService {
|
|
|
414
420
|
//
|
|
415
421
|
// So the lookup WAITS. A few hundred milliseconds of polling covers the drain, and a question that
|
|
416
422
|
// genuinely has no pending call still fails - just not instantly.
|
|
417
|
-
const findCall = () => {
|
|
418
|
-
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
419
|
-
const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
|
|
420
|
-
if (!message)
|
|
421
|
-
return undefined;
|
|
422
|
-
for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
|
|
423
|
-
const call = message.toolCalls?.[i];
|
|
424
|
-
if (call?.label === "question" && call.question?.status === "pending")
|
|
425
|
-
return { messageId: message.id, index: i };
|
|
426
|
-
}
|
|
427
|
-
return undefined;
|
|
428
|
-
};
|
|
429
423
|
let found;
|
|
430
424
|
for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
|
|
431
|
-
found =
|
|
425
|
+
found = this.store.pendingQuestion(conversationId);
|
|
432
426
|
if (found)
|
|
433
427
|
break;
|
|
434
428
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
435
429
|
}
|
|
436
|
-
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
437
|
-
if (!conversation?.messages.length)
|
|
438
|
-
throw new Error("No active assistant message");
|
|
439
430
|
if (!found)
|
|
440
431
|
throw new Error("这个会话里没有正在等待回答的 question 工具调用");
|
|
441
432
|
const { messageId, index } = found;
|
|
@@ -445,29 +436,7 @@ export class RunService {
|
|
|
445
436
|
// continuing from a question that was never decided). The run-level teardown (stop / abort /
|
|
446
437
|
// shutdown) clears the underlying ask and the client settles it as cancelled, which lands here
|
|
447
438
|
// and rejects. A question that is simply unanswered waits indefinitely.
|
|
448
|
-
|
|
449
|
-
const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
|
|
450
|
-
if (current?.status === "answered") {
|
|
451
|
-
clearInterval(poll);
|
|
452
|
-
resolve(answerPayload(current));
|
|
453
|
-
}
|
|
454
|
-
else if (current?.status === "cancelled") {
|
|
455
|
-
clearInterval(poll);
|
|
456
|
-
reject(new Error("用户没有回答这个问题"));
|
|
457
|
-
}
|
|
458
|
-
}, 250);
|
|
459
|
-
// WHAT GOES BACK TO THE MODEL IS WRITTEN FOR THE MODEL. The answers pair to their questions by id,
|
|
460
|
-
// which is what stops a person answering the second of two being recorded against the first - but an
|
|
461
|
-
// id is a UUID, and a model handed `{answers:[{id:"671b0f0b-…", selected:["发"]}]}` has to guess at
|
|
462
|
-
// what that means. It guessed, and reported a real answer as a cancellation. So the answer goes back
|
|
463
|
-
// as its questions and its choices, with a sentence naming the outcome.
|
|
464
|
-
const answerPayload = (current) => {
|
|
465
|
-
const answers = (current.answers ?? []).map((answer) => {
|
|
466
|
-
const asked = current.items.find((item) => item.id === answer.id);
|
|
467
|
-
return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
|
|
468
|
-
});
|
|
469
|
-
return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
|
|
470
|
-
};
|
|
439
|
+
this.questionWaiters.set(this.questionKey(conversationId, messageId, index), { resolve, reject });
|
|
471
440
|
});
|
|
472
441
|
}
|
|
473
442
|
/**
|
|
@@ -571,6 +540,7 @@ export class RunService {
|
|
|
571
540
|
// Abort immediately before waiting for the conversation lane; otherwise a stop queued behind a
|
|
572
541
|
// long streaming command could never reach the controller that it is meant to cancel.
|
|
573
542
|
this.activeRuns.get(conversationId)?.abort();
|
|
543
|
+
this.broadcaster.broadcast({ type: "run.stopping", conversationId });
|
|
574
544
|
await this.runtime.stop?.(conversationId);
|
|
575
545
|
return this.serializeConversation(conversationId, () => this.stopRunUnsafe(conversationId));
|
|
576
546
|
}
|
|
@@ -591,9 +561,9 @@ export class RunService {
|
|
|
591
561
|
continue;
|
|
592
562
|
const calls = await this.store.updateToolCall(conversationId, message.id, index, { question: { items: question.items, status: "cancelled" } });
|
|
593
563
|
this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: message.id, toolCalls: calls });
|
|
564
|
+
this.settleQuestion(conversationId, message.id, index, calls[index]?.question);
|
|
594
565
|
}
|
|
595
566
|
}
|
|
596
|
-
await this.store.setStatus(conversationId, "stopped");
|
|
597
567
|
if (this.subagents.stopConversation(conversationId)) {
|
|
598
568
|
console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
|
|
599
569
|
}
|
|
@@ -603,6 +573,17 @@ export class RunService {
|
|
|
603
573
|
if (this.workflows.stopConversation(conversationId)) {
|
|
604
574
|
console.log(`Super Pi: stopped the running orchestration plans of conversation ${conversationId}.`);
|
|
605
575
|
}
|
|
576
|
+
// AWAIT RUN SLOT RELEASE BEFORE CLAIMING "STOPPED".
|
|
577
|
+
// A stop previously called setStatus("stopped") immediately, while activeRuns still held the
|
|
578
|
+
// conversation slot and the streaming lane was still draining. This produced the contradiction:
|
|
579
|
+
// the UI displayed 「已停止」 and offered 「发送」, but sending a new message hit an active
|
|
580
|
+
// run slot and was falsely placed into 「排队中」, while the previous turn might still produce text.
|
|
581
|
+
await this.awaitRunSlotReleased(conversationId, false, 3_000);
|
|
582
|
+
if (this.activeRuns.has(conversationId)) {
|
|
583
|
+
console.warn(`Super Pi: run slot for conversation ${conversationId} not released after stop deadline; force-releasing.`);
|
|
584
|
+
this.releaseRunSlot(conversationId);
|
|
585
|
+
}
|
|
586
|
+
await this.store.setStatus(conversationId, "stopped");
|
|
606
587
|
this.broadcaster.broadcastState();
|
|
607
588
|
}
|
|
608
589
|
/**
|
|
@@ -777,14 +758,29 @@ export class RunService {
|
|
|
777
758
|
// long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
|
|
778
759
|
// every delta would be a per-token status write.
|
|
779
760
|
let firstSignal = true;
|
|
761
|
+
/**
|
|
762
|
+
* Promotes the turn to `running`, and ANSWERS WHETHER IT PUBLISHED A STATE SNAPSHOT.
|
|
763
|
+
*
|
|
764
|
+
* The answer matters because a state snapshot and a live event are two carriers of the same
|
|
765
|
+
* information: the snapshot is the whole truth (it carries the text, the thinking and the block
|
|
766
|
+
* boundaries), and a live `message.delta` / `message.thinking` event is an increment on top of it.
|
|
767
|
+
* A caller that has ALREADY put a delta into the store must therefore not also publish it as a
|
|
768
|
+
* live event, or the browser receives it twice and counts it twice - the reasoning the reader
|
|
769
|
+
* sees is longer than the reasoning the model wrote.
|
|
770
|
+
*
|
|
771
|
+
* So the rule is: WHOEVER PUTS THE DELTA IN THE STORE OWNS THE DELIVERY. The promotion publishes
|
|
772
|
+
* a snapshot only when it actually flips the status, and the caller asks.
|
|
773
|
+
*/
|
|
780
774
|
const promoteToRunning = async () => {
|
|
781
775
|
if (!firstSignal)
|
|
782
|
-
return;
|
|
776
|
+
return false;
|
|
783
777
|
firstSignal = false;
|
|
784
778
|
if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
|
|
785
779
|
await this.store.setStatus(conversation.id, "running");
|
|
786
780
|
this.broadcaster.broadcastState();
|
|
781
|
+
return true;
|
|
787
782
|
}
|
|
783
|
+
return false;
|
|
788
784
|
};
|
|
789
785
|
// Live events are independent from durable checkpoints. Persisting every token made the stdout
|
|
790
786
|
// reader wait for one disk transaction per token; over a remote browser connection that backlog
|
|
@@ -1152,14 +1148,27 @@ export class RunService {
|
|
|
1152
1148
|
thinkingActive = true;
|
|
1153
1149
|
this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
|
|
1154
1150
|
thought += delta.length;
|
|
1151
|
+
// THE DELTA GOES INTO THE STORE FIRST, and the promotion that follows broadcasts a state
|
|
1152
|
+
// snapshot that therefore CARRIES it - the text, the thinking and the block boundary the
|
|
1153
|
+
// browser needs to put this fragment in the right fold. That snapshot is the delta's
|
|
1154
|
+
// delivery, so the live event is not sent as well: sent twice, the browser appends the
|
|
1155
|
+
// first fragment to a copy that already has it, and the reasoning the reader sees is longer
|
|
1156
|
+
// than the reasoning the model wrote.
|
|
1157
|
+
//
|
|
1158
|
+
// THE OTHER ORDER HAD ITS OWN FAILURE, and it is why the snapshot is not simply left to
|
|
1159
|
+
// carry everything: promoting BEFORE the append publishes a snapshot WITHOUT this delta,
|
|
1160
|
+
// and the browser - which rebuilds its fold blocks from the snapshot's boundaries - then
|
|
1161
|
+
// starts a second block for the same stretch of reasoning, so one continuous train of
|
|
1162
|
+
// thought arrives as two fold bars and the tool call inside it falls out of the block it
|
|
1163
|
+
// belonged to.
|
|
1164
|
+
//
|
|
1165
|
+
// So: the snapshot carries the delta, and only the deltas the snapshot did not carry are
|
|
1166
|
+
// published live. `promoteToRunning` answers whether it published one.
|
|
1155
1167
|
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
// boundary, and dropped a tool call out of the block it belonged to.
|
|
1161
|
-
await promoteToRunning();
|
|
1162
|
-
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
1168
|
+
const promoted = await promoteToRunning();
|
|
1169
|
+
if (!promoted) {
|
|
1170
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
1171
|
+
}
|
|
1163
1172
|
scheduleCheckpoint();
|
|
1164
1173
|
},
|
|
1165
1174
|
// AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
|
|
@@ -1324,6 +1333,12 @@ export class RunService {
|
|
|
1324
1333
|
// itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
|
|
1325
1334
|
// going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
|
|
1326
1335
|
// what two fast sends looks like from the inside.
|
|
1336
|
+
// An aborted run is unwinding its slot. Wait out that brief cleanup rather than falsely
|
|
1337
|
+
// treating it as an active turn and sending this message to a dying follow-up queue.
|
|
1338
|
+
const active = this.activeRuns.get(conversation.id);
|
|
1339
|
+
if (active?.signal.aborted) {
|
|
1340
|
+
await this.awaitRunSlotReleased(conversation.id, false);
|
|
1341
|
+
}
|
|
1327
1342
|
const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
|
|
1328
1343
|
const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
|
|
1329
1344
|
this.broadcaster.broadcastState();
|
|
@@ -1375,6 +1390,9 @@ export class RunService {
|
|
|
1375
1390
|
// waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
|
|
1376
1391
|
// survive a retry into the same conversation.
|
|
1377
1392
|
await this.store.setFailureReason(conversationId, formatTurnFailure(error));
|
|
1393
|
+
// The refusal is written for the reader, but the reader is not the only one who needs to know
|
|
1394
|
+
// why a turn never started: the server log is where the stack survives.
|
|
1395
|
+
console.warn("Super Pi turn refused before it started:", describeThrownWithStack(error));
|
|
1378
1396
|
// Status is restored here, not relied on the outer catch to handle: a preflight failure never
|
|
1379
1397
|
// opened a turn, so the right state is the one the conversation had a moment ago (idle). The
|
|
1380
1398
|
// outer catch would otherwise flip it to `failed`, which the reader would render as a turn
|
|
@@ -1424,6 +1442,9 @@ export class RunService {
|
|
|
1424
1442
|
if (controller.signal.aborted)
|
|
1425
1443
|
return;
|
|
1426
1444
|
await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
|
|
1445
|
+
// A turn that broke is the failure the reader will complain about, and the banner only carries
|
|
1446
|
+
// the sentence. The stack goes to the server log, where it can still be found.
|
|
1447
|
+
console.error("Super Pi turn failed:", describeThrownWithStack(error));
|
|
1427
1448
|
throw error;
|
|
1428
1449
|
}
|
|
1429
1450
|
finally {
|
|
@@ -1460,8 +1481,8 @@ export class RunService {
|
|
|
1460
1481
|
* and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
|
|
1461
1482
|
* whenever the current answer finishes", which is what the queue is for.
|
|
1462
1483
|
*/
|
|
1463
|
-
async awaitRunSlotReleased(conversationId, onlyWhileStopped = false) {
|
|
1464
|
-
const deadline = Date.now() +
|
|
1484
|
+
async awaitRunSlotReleased(conversationId, onlyWhileStopped = false, timeoutMs = 15_000) {
|
|
1485
|
+
const deadline = Date.now() + timeoutMs;
|
|
1465
1486
|
while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
|
|
1466
1487
|
if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
|
|
1467
1488
|
return;
|
|
@@ -147,6 +147,13 @@ export declare class ScheduledTaskService {
|
|
|
147
147
|
* one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
|
|
148
148
|
* cannot be silently ignored, because the run would then happen on some other model and the panel
|
|
149
149
|
* would say it succeeded.
|
|
150
|
+
*
|
|
151
|
+
* AND WHEN THERE IS NO OVERRIDE, THE DEFAULTS BEHIND IT GO ON THE CONVERSATION TOO. Clearing the
|
|
152
|
+
* override in the panel means 「跟着工作区默认走」, and the only way to honour that on a conversation
|
|
153
|
+
* that already ran with the override is to PUT THE DEFAULT BACK. The conversation's model is a
|
|
154
|
+
* copy-at-birth value (AGENTS.md: 建会话时把默认值盖到会话字段上,之后永不回查工作区), so "do
|
|
155
|
+
* nothing when there is no override" keeps the WITHDRAWN override pinned for good - and the panel
|
|
156
|
+
* says the run succeeded on a model the user just took away.
|
|
150
157
|
*/
|
|
151
158
|
private applyConfiguration;
|
|
152
159
|
/** Writes down how the run went, and tells the panel. */
|
|
@@ -302,14 +302,23 @@ export class ScheduledTaskService {
|
|
|
302
302
|
* one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
|
|
303
303
|
* cannot be silently ignored, because the run would then happen on some other model and the panel
|
|
304
304
|
* would say it succeeded.
|
|
305
|
+
*
|
|
306
|
+
* AND WHEN THERE IS NO OVERRIDE, THE DEFAULTS BEHIND IT GO ON THE CONVERSATION TOO. Clearing the
|
|
307
|
+
* override in the panel means 「跟着工作区默认走」, and the only way to honour that on a conversation
|
|
308
|
+
* that already ran with the override is to PUT THE DEFAULT BACK. The conversation's model is a
|
|
309
|
+
* copy-at-birth value (AGENTS.md: 建会话时把默认值盖到会话字段上,之后永不回查工作区), so "do
|
|
310
|
+
* nothing when there is no override" keeps the WITHDRAWN override pinned for good - and the panel
|
|
311
|
+
* says the run succeeded on a model the user just took away.
|
|
305
312
|
*/
|
|
306
313
|
async applyConfiguration(task, conversationId) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
await this.conversationService.
|
|
314
|
+
const state = this.store.snapshot();
|
|
315
|
+
const workspace = state.workspaces.find((item) => item.id === task.workspaceId);
|
|
316
|
+
const model = task.model ?? workspace?.defaultModel ?? state.defaultModel;
|
|
317
|
+
if (model) {
|
|
318
|
+
await this.conversationService.setModel(conversationId, model.provider, model.modelId);
|
|
312
319
|
}
|
|
320
|
+
const agentId = task.agentId ?? workspace?.defaultAgentId ?? null;
|
|
321
|
+
await this.conversationService.setAgent(conversationId, agentId);
|
|
313
322
|
}
|
|
314
323
|
/** Writes down how the run went, and tells the panel. */
|
|
315
324
|
async record(task, outcome) {
|
|
@@ -22,9 +22,8 @@ export declare class WorkspaceService {
|
|
|
22
22
|
private readonly catalogService;
|
|
23
23
|
private readonly runService;
|
|
24
24
|
private readonly extensionUiService;
|
|
25
|
-
private readonly uploadsDir;
|
|
26
25
|
private agentsDir;
|
|
27
|
-
constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService,
|
|
26
|
+
constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService, agentsDir: string | string[]);
|
|
28
27
|
setAgentsDir(dir: string | string[]): void;
|
|
29
28
|
/**
|
|
30
29
|
* Creates a new workspace.
|
|
@@ -35,8 +34,13 @@ export declare class WorkspaceService {
|
|
|
35
34
|
*/
|
|
36
35
|
renameWorkspace(workspaceId: string, name: string): Promise<void>;
|
|
37
36
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Takes a workspace out of the sidebar. NOTHING IS DELETED.
|
|
38
|
+
*
|
|
39
|
+
* The conversations under it, the uploads attached to them and the workspace's own defaults all
|
|
40
|
+
* stay on disk: removing a workspace from the list is a soft unmount, and adding the same
|
|
41
|
+
* folder back brings the whole history with it. What does stop is the WORK: a turn that was still
|
|
42
|
+
* running cannot keep a model session alive behind a workspace the user can no longer see, and a
|
|
43
|
+
* pending question would wait for an answer the sidebar can no longer reach.
|
|
40
44
|
*/
|
|
41
45
|
deleteWorkspace(workspaceId: string): Promise<void>;
|
|
42
46
|
/**
|