@evandu/super-pi 0.1.85 → 0.1.87
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/index.ts +2 -2
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +10 -8
- package/dist/client/assets/index-BfK8kaCv.css +1 -0
- package/dist/client/assets/index-Dh3QFthH.js +73 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.js +48 -9
- package/dist/server/http-server.js +159 -7
- package/dist/server/index.js +8 -1
- package/dist/server/runtime.d.ts +34 -0
- package/dist/server/runtime.js +45 -0
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/store.d.ts +19 -0
- package/dist/server/store.js +31 -0
- package/dist/server/tool-llm.d.ts +65 -0
- package/dist/server/tool-llm.js +139 -0
- package/dist/server/tool-registry.d.ts +65 -0
- package/dist/server/tool-registry.js +129 -6
- package/dist/server/tool-runner.d.ts +70 -0
- package/dist/server/tool-runner.js +210 -10
- package/package.json +4 -1
- package/dist/client/assets/index-B_jOwjyp.js +0 -72
- package/dist/client/assets/index-CNNGl4eF.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-Dh3QFthH.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BfK8kaCv.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -838,24 +838,63 @@ export class RunService {
|
|
|
838
838
|
// Written first, the message is in the transcript in the same frame, whether it starts a turn or
|
|
839
839
|
// waits for one, and the guard below only decides WHICH. It also means the client-supplied `clientId`
|
|
840
840
|
// is attached to a real message either way, so there is nothing to reconcile afterwards.
|
|
841
|
-
|
|
841
|
+
//
|
|
842
|
+
// BUT "WHICH" IS DECIDED BEFORE THE WRITE, not at the guard, and that ordering is the fix for a bug
|
|
843
|
+
// the other order cannot fix: a message that is going to start a turn has no business passing through
|
|
844
|
+
// 「等待回答」 on its way there. Send into an idle conversation and the reader watches a queue badge
|
|
845
|
+
// light up, a row appear in the dock, and both disappear a frame later - a queue that was never one,
|
|
846
|
+
// for a turn that had already begun. The state a message is born into is a claim about it, and a claim
|
|
847
|
+
// retracted in the next frame is worse than never making it.
|
|
848
|
+
//
|
|
849
|
+
// Reading the slot here and claiming it below leaves a window between them, and the window is real:
|
|
850
|
+
// `message.send` runs outside the socket's command queue precisely so a follow-up can overtake a
|
|
851
|
+
// command already in flight, so two sends CAN be inside this method at the same time. The loser wrote
|
|
852
|
+
// itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
|
|
853
|
+
// going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
|
|
854
|
+
// what two fast sends looks like from the inside.
|
|
855
|
+
const busy = this.activeRuns.has(conversation.id);
|
|
856
|
+
const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
|
|
842
857
|
this.broadcaster.broadcastState();
|
|
843
858
|
// Claim the run slot synchronously (no await between has() and set()) so two
|
|
844
859
|
// rapid sends can never both pass the guard and start concurrent turns.
|
|
845
|
-
if (this.activeRuns.has(conversation.id))
|
|
860
|
+
if (this.activeRuns.has(conversation.id)) {
|
|
861
|
+
if (!busy) {
|
|
862
|
+
// Lost the race described above. No turn has opened on this message and no model has seen it, so
|
|
863
|
+
// calling it waiting is a statement of fact - and it puts the words exactly where a message born
|
|
864
|
+
// waiting would have put them, which is the only place a person can find them.
|
|
865
|
+
await this.store.markMessageWaiting(conversationId, message.id);
|
|
866
|
+
this.broadcaster.broadcastState();
|
|
867
|
+
}
|
|
846
868
|
return;
|
|
869
|
+
}
|
|
847
870
|
const controller = new AbortController();
|
|
848
871
|
this.activeRuns.set(conversation.id, controller);
|
|
849
872
|
try {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
873
|
+
let expanded;
|
|
874
|
+
try {
|
|
875
|
+
if (validImages?.length) {
|
|
876
|
+
const catalog = await this.runtime.catalog(conversation.model);
|
|
877
|
+
const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
|
|
878
|
+
?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
|
|
879
|
+
if (currentModel && !currentModel.imageInput) {
|
|
880
|
+
throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
|
|
881
|
+
}
|
|
856
882
|
}
|
|
883
|
+
expanded = await expandAtReferences(this.store, workspace, trimmed);
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
// EVERY REFUSAL ABOVE HAPPENS BEFORE A TURN EXISTS. The words are on screen and no model has seen
|
|
887
|
+
// them, so failing here must not leave a bubble that nothing will ever answer: they go back to
|
|
888
|
+
// the queue, where they can be seen, cancelled or sent again. On the queueing path this is the
|
|
889
|
+
// state they were born in and nothing changed; on the idle path it is the difference between
|
|
890
|
+
// 「消息发出去了但没有回答」 and a message the person can still act on.
|
|
891
|
+
//
|
|
892
|
+
// The boundary is the shape of the code, not a flag: `runConversationTurns` opens the turn on its
|
|
893
|
+
// first line, so it is outside this try for exactly that reason. A failure from in there is a turn
|
|
894
|
+
// that has already consumed these words, and putting them back would send them twice.
|
|
895
|
+
await this.store.markMessageWaiting(conversationId, message.id).catch(() => undefined);
|
|
896
|
+
throw error;
|
|
857
897
|
}
|
|
858
|
-
const expanded = await expandAtReferences(this.store, workspace, trimmed);
|
|
859
898
|
await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
|
|
860
899
|
await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
|
|
861
900
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createReadStream, existsSync } from "node:fs";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { homedir, networkInterfaces } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
@@ -10,6 +10,7 @@ import { checkDiskVersion } from "./version.js";
|
|
|
10
10
|
import { gitDiff, gitLog, gitStatus } from "./git.js";
|
|
11
11
|
import { verifyCapability } from "./capability.js";
|
|
12
12
|
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
13
|
+
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
13
14
|
import { packageUpdateArgs } from "./update.js";
|
|
14
15
|
const MIME_TYPES = {
|
|
15
16
|
".css": "text/css; charset=utf-8",
|
|
@@ -50,6 +51,28 @@ function toolBrowserSdk() {
|
|
|
50
51
|
}
|
|
51
52
|
},
|
|
52
53
|
runAndWait: async (params = {}, options) => { const run = await api.run(params); return api.wait(run.id, options); },
|
|
54
|
+
// THE MODEL, ASKED ON THE TOOL'S BEHALF. One call, one answer, no client to write: Super Pi owns the
|
|
55
|
+
// credentials, the model choice and the ceilings, and the tool only ever sees the text. It is a
|
|
56
|
+
// completion, not an agent - the model that answers has no tools, no files and no session history.
|
|
57
|
+
// Only a tool registered with permissions.llm may call it; otherwise the server refuses.
|
|
58
|
+
llm: (input = {}) => request("./api/llm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }),
|
|
59
|
+
llmGet: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "GET" }),
|
|
60
|
+
llmCancel: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "DELETE" }),
|
|
61
|
+
llmWait: async (callId, { intervalMs = 400, signal } = {}) => {
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
64
|
+
const call = await api.llmGet(callId);
|
|
65
|
+
if (call.status !== "running") {
|
|
66
|
+
if (call.status !== "succeeded") throw new Error(call.error || "Super Pi's model call did not succeed");
|
|
67
|
+
return call;
|
|
68
|
+
}
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(resolve, Math.max(50, intervalMs));
|
|
71
|
+
if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
llmText: async (input = {}, options) => { const call = await api.llm(input); return (await api.llmWait(call.id, options)).text; },
|
|
53
76
|
};
|
|
54
77
|
Object.freeze(api);
|
|
55
78
|
Object.defineProperty(window, "SuperPiTool", { value: api, writable: false, configurable: false });
|
|
@@ -206,10 +229,35 @@ export function createHttpServer(options) {
|
|
|
206
229
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
207
230
|
const remote = request.socket.remoteAddress ?? "";
|
|
208
231
|
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
|
|
232
|
+
if (url.pathname === "/api/tools/export") {
|
|
233
|
+
// NOT EXEMPT FROM THE TOKEN CHECK ABOVE, and that is deliberate: an export hands out a tool's
|
|
234
|
+
// whole source, so it is a management read exactly like GET and DELETE, not a capability call.
|
|
235
|
+
try {
|
|
236
|
+
const id = url.searchParams.get("id");
|
|
237
|
+
if (!id) {
|
|
238
|
+
json(400, { error: "id required" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const exported = await toolRegistry.exportBundle(id);
|
|
242
|
+
if (!exported) {
|
|
243
|
+
json(404, { error: "tool not found" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
json(200, exported);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
json(400, { error: error instanceof Error ? error.message : "tool export failed" });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
209
254
|
if (url.pathname === "/api/tools") {
|
|
210
255
|
try {
|
|
211
256
|
if (request.method === "GET") {
|
|
212
|
-
|
|
257
|
+
// The environment state is DERIVED per request and deliberately not stored: it changes when
|
|
258
|
+
// the user installs Python, when a tool's venv is built, and when nothing at all happens.
|
|
259
|
+
const tools = await toolRegistry.list();
|
|
260
|
+
json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
|
|
213
261
|
return;
|
|
214
262
|
}
|
|
215
263
|
if (request.method === "DELETE") {
|
|
@@ -219,6 +267,11 @@ export function createHttpServer(options) {
|
|
|
219
267
|
return;
|
|
220
268
|
}
|
|
221
269
|
const removed = await toolRegistry.remove(id);
|
|
270
|
+
// The venv is machine-local state that belongs to that tool and nothing else, so it goes with
|
|
271
|
+
// it. Leaving it behind would mean a re-registered tool of the same slug silently inherits a
|
|
272
|
+
// stale environment.
|
|
273
|
+
if (removed)
|
|
274
|
+
await rm(toolRunner.envDir(id), { recursive: true, force: true });
|
|
222
275
|
if (removed)
|
|
223
276
|
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
224
277
|
json(removed ? 200 : 404, { ok: removed });
|
|
@@ -228,13 +281,18 @@ export function createHttpServer(options) {
|
|
|
228
281
|
json(405, { error: "method" });
|
|
229
282
|
return;
|
|
230
283
|
}
|
|
231
|
-
|
|
284
|
+
// BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
|
|
285
|
+
// units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
|
|
286
|
+
// where the real ceiling was and an exported bundle could be refused on the way back in.
|
|
287
|
+
let raw = "", bytes = 0;
|
|
232
288
|
for await (const chunk of request) {
|
|
233
|
-
|
|
234
|
-
|
|
289
|
+
const piece = chunk;
|
|
290
|
+
bytes += piece.length;
|
|
291
|
+
if (bytes > MAX_TOOL_BODY_BYTES) {
|
|
235
292
|
json(413, { error: "request too large" });
|
|
236
293
|
return;
|
|
237
294
|
}
|
|
295
|
+
raw += piece.toString("utf8");
|
|
238
296
|
}
|
|
239
297
|
const body = JSON.parse(raw);
|
|
240
298
|
if (!isAuthorized(request, accessToken)) {
|
|
@@ -252,7 +310,16 @@ export function createHttpServer(options) {
|
|
|
252
310
|
json(400, { error: "slug, name and files are required" });
|
|
253
311
|
return;
|
|
254
312
|
}
|
|
255
|
-
|
|
313
|
+
// AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
|
|
314
|
+
// and verified, and overwriting it with the importer's own machine would erase the very
|
|
315
|
+
// difference the tool list exists to show. A tool with no fingerprint gets one measured here.
|
|
316
|
+
let environment = body.environment;
|
|
317
|
+
if (environment && !environment.fingerprint) {
|
|
318
|
+
const local = await toolRunner.describeLocalPython();
|
|
319
|
+
if (local)
|
|
320
|
+
environment = { ...environment, fingerprint: { python: local.python, platform: local.platform, packages: environment.requires } };
|
|
321
|
+
}
|
|
322
|
+
const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
|
|
256
323
|
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
257
324
|
json(201, tool);
|
|
258
325
|
return;
|
|
@@ -316,7 +383,7 @@ export function createHttpServer(options) {
|
|
|
316
383
|
toolApi(404, { error: "python script is not registered" });
|
|
317
384
|
return;
|
|
318
385
|
}
|
|
319
|
-
const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
|
|
386
|
+
const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {}, environment: resolved.tool.environment });
|
|
320
387
|
toolApi(202, run);
|
|
321
388
|
}
|
|
322
389
|
catch (error) {
|
|
@@ -363,6 +430,91 @@ export function createHttpServer(options) {
|
|
|
363
430
|
toolApi(405, { error: "method" });
|
|
364
431
|
return;
|
|
365
432
|
}
|
|
433
|
+
// ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
|
|
434
|
+
// A tool asking the model a question, over the same relative API as its Python jobs, so the frame
|
|
435
|
+
// still never learns an id, a path or a credential. Three gates, in this order:
|
|
436
|
+
// 1. the opaque sandbox origin - identical to every other tool API here;
|
|
437
|
+
// 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
|
|
438
|
+
// the person before they accepted the file. A tool that quietly started asking the model
|
|
439
|
+
// would be spending the user's quota from inside a sandbox they were told was isolated;
|
|
440
|
+
// 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
|
|
441
|
+
const llmStart = /^api\/llm$/.exec(resource);
|
|
442
|
+
const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
|
|
443
|
+
if (llmStart || llmRun) {
|
|
444
|
+
if (!toolOrigin) {
|
|
445
|
+
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
|
|
449
|
+
// depend on whether this machine happens to have a model, so a tool with no grant gets the same
|
|
450
|
+
// 403 whether or not Pi is installed.
|
|
451
|
+
if (!listed.permissions?.llm) {
|
|
452
|
+
toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!context.toolLlm?.available) {
|
|
456
|
+
toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (llmStart) {
|
|
460
|
+
if (request.method === "OPTIONS") {
|
|
461
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (request.method !== "POST") {
|
|
465
|
+
toolApi(405, { error: "method" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
let raw = "";
|
|
469
|
+
for await (const chunk of request) {
|
|
470
|
+
raw += chunk;
|
|
471
|
+
if (raw.length > 64_000) {
|
|
472
|
+
toolApi(413, { error: "request too large" });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const body = JSON.parse(raw);
|
|
478
|
+
toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
toolApi(400, { error: error instanceof Error ? error.message : "model call failed" });
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (request.method === "OPTIONS") {
|
|
486
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
let llmId;
|
|
490
|
+
try {
|
|
491
|
+
llmId = decodeURIComponent(llmRun[1]);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
toolApi(400, { error: "Malformed tool resource" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (request.method === "GET") {
|
|
498
|
+
const run = context.toolLlm.get(llmId, id);
|
|
499
|
+
if (!run) {
|
|
500
|
+
toolApi(404, { error: "model call not found" });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
toolApi(200, run);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (request.method === "DELETE") {
|
|
507
|
+
const run = context.toolLlm.cancel(llmId, id);
|
|
508
|
+
if (!run) {
|
|
509
|
+
toolApi(404, { error: "model call not found" });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
toolApi(200, run);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
toolApi(405, { error: "method" });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
366
518
|
if (resource === "super-pi-tool.js") {
|
|
367
519
|
response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
|
|
368
520
|
return;
|
package/dist/server/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { WsBroadcaster, WsServer } from "./ws-server.js";
|
|
|
26
26
|
import { initRunningVersion } from "./version.js";
|
|
27
27
|
import { ToolRegistry } from "./tool-registry.js";
|
|
28
28
|
import { ToolRunner } from "./tool-runner.js";
|
|
29
|
+
import { ToolLlmService } from "./tool-llm.js";
|
|
29
30
|
// ------------------------------------------------------------- Environment & paths
|
|
30
31
|
const rootDir = resolve(import.meta.dirname, "../..");
|
|
31
32
|
initRunningVersion(rootDir);
|
|
@@ -54,7 +55,9 @@ const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
|
|
|
54
55
|
// ------------------------------------------------------------- Core singletons
|
|
55
56
|
const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
|
|
56
57
|
const toolRegistry = new ToolRegistry(dataDir);
|
|
57
|
-
|
|
58
|
+
// The data dir is what makes a per-tool virtual environment possible: it lives at
|
|
59
|
+
// `<dataDir>/tool-envs/<toolId>`, beside the tools rather than inside one.
|
|
60
|
+
const toolRunner = new ToolRunner(dataDir);
|
|
58
61
|
const initialAgentDirs = resolveAgentDirectories({
|
|
59
62
|
customDirs: store.snapshot().customAgentDirs,
|
|
60
63
|
rootDir,
|
|
@@ -77,6 +80,9 @@ const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
|
|
|
77
80
|
process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
|
|
78
81
|
if (runtime instanceof PiRpcRuntime)
|
|
79
82
|
runtime.setMemory(memory);
|
|
83
|
+
// The model a mounted tool may ask. It IS Pi: `PiRpcRuntime.askOnce` spawns its own child with nothing
|
|
84
|
+
// attached but Pi, and a runtime without one reports itself unavailable rather than faking an answer.
|
|
85
|
+
const toolLlm = new ToolLlmService(runtime, rootDir);
|
|
80
86
|
const knowledge = new KnowledgeBase(join(dataDir, "knowledge"), store, runtime, new VectorIndex(join(dataDir, "knowledge")));
|
|
81
87
|
process.env.SUPER_PI_KNOWLEDGE_ENDPOINT = `http://${internalHost}:${port}/api/knowledge/tool`;
|
|
82
88
|
if (runtime instanceof PiRpcRuntime)
|
|
@@ -169,6 +175,7 @@ const serverContext = {
|
|
|
169
175
|
execService,
|
|
170
176
|
toolRegistry,
|
|
171
177
|
toolRunner,
|
|
178
|
+
toolLlm,
|
|
172
179
|
terminalEnabled: () => terminalService.enabled,
|
|
173
180
|
terminalDisabledReason: () => terminalDisabledReason(),
|
|
174
181
|
};
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -245,6 +245,30 @@ export interface AgentRuntime {
|
|
|
245
245
|
* Absent on the welcome runtime, which has no Pi behind it.
|
|
246
246
|
*/
|
|
247
247
|
summarizeConversation?(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
|
|
248
|
+
/**
|
|
249
|
+
* ONE-SHOT COMPLETION, FOR A MOUNTED TOOL. A throwaway Pi child, one prompt, one answer, then gone.
|
|
250
|
+
*
|
|
251
|
+
* WHY IT IS A SEPARATE CHILD, LIKE `summarizeConversation`: a turn's child is the one reading the
|
|
252
|
+
* user's stream. Writing a tool's prompt at it would land in the conversation the user is watching.
|
|
253
|
+
*
|
|
254
|
+
* WHY NOTHING IS ATTACHED BUT Pi ITSELF. `--no-extensions --no-skills --no-mcp --no-context-files
|
|
255
|
+
* --no-prompt-templates`, the same line the knowledge reranker uses: a tool asking the model a question
|
|
256
|
+
* must not be able to reach the user's files, the task tools, or a session history. A model that
|
|
257
|
+
* spends the turn on a tool call anyway gets no answer and the call times out - the same accepted
|
|
258
|
+
* failure mode the reranker documents.
|
|
259
|
+
*
|
|
260
|
+
* WHY IT CHOOSES NO MODEL. Pi's own default is the user's own default, and a sandboxed frame is the
|
|
261
|
+
* last place that should get to pick which model spends the user's quota.
|
|
262
|
+
*
|
|
263
|
+
* Absent on a runtime with no Pi behind it, which the caller reports as a refusal rather than
|
|
264
|
+
* pretending a model answered.
|
|
265
|
+
*/
|
|
266
|
+
askOnce?(input: {
|
|
267
|
+
prompt: string;
|
|
268
|
+
timeoutMs: number;
|
|
269
|
+
cwd: string;
|
|
270
|
+
signal?: AbortSignal;
|
|
271
|
+
}): Promise<string>;
|
|
248
272
|
/**
|
|
249
273
|
* The agent a conversation runs as, read fresh from the agent directory. Absent on a runtime with
|
|
250
274
|
* no agents (the welcome runtime), where every conversation runs as Pi itself. Callers that need an
|
|
@@ -669,6 +693,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
669
693
|
* model; in the second the outcome is genuinely unknown and a `sending` state says so.
|
|
670
694
|
*/
|
|
671
695
|
summarizeConversation(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
|
|
696
|
+
/**
|
|
697
|
+
* A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
|
|
698
|
+
* why nothing is attached but Pi, and why it picks no model.
|
|
699
|
+
*/
|
|
700
|
+
askOnce(input: {
|
|
701
|
+
prompt: string;
|
|
702
|
+
timeoutMs: number;
|
|
703
|
+
cwd: string;
|
|
704
|
+
signal?: AbortSignal;
|
|
705
|
+
}): Promise<string>;
|
|
672
706
|
steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
673
707
|
/**
|
|
674
708
|
* The model/thinking catalog, from Pi, cached.
|
package/dist/server/runtime.js
CHANGED
|
@@ -1360,6 +1360,51 @@ export class PiRpcRuntime {
|
|
|
1360
1360
|
await killTree(child.pid);
|
|
1361
1361
|
}
|
|
1362
1362
|
}
|
|
1363
|
+
/**
|
|
1364
|
+
* A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
|
|
1365
|
+
* why nothing is attached but Pi, and why it picks no model.
|
|
1366
|
+
*/
|
|
1367
|
+
async askOnce(input) {
|
|
1368
|
+
if (!this.pi.found)
|
|
1369
|
+
throw new Error(this.pi.reason);
|
|
1370
|
+
// A throwaway session id, for the same reason the autotitle child gets one: this exchange is not
|
|
1371
|
+
// part of any conversation's transcript and must never be filed under one.
|
|
1372
|
+
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `tool-llm-${randomUUID()}`, "--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"], {
|
|
1373
|
+
cwd: resolve(input.cwd),
|
|
1374
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1375
|
+
shell: process.platform === "win32",
|
|
1376
|
+
});
|
|
1377
|
+
const channel = new RpcChannel(child);
|
|
1378
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
1379
|
+
try {
|
|
1380
|
+
channel.write({ id: "tool_llm", type: "prompt", message: input.prompt });
|
|
1381
|
+
let settled = false, text = "";
|
|
1382
|
+
while (!settled && Date.now() < deadline) {
|
|
1383
|
+
if (input.signal?.aborted)
|
|
1384
|
+
throw new Error("已取消");
|
|
1385
|
+
const record = await channel.next(100);
|
|
1386
|
+
if (!record)
|
|
1387
|
+
continue;
|
|
1388
|
+
if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta")
|
|
1389
|
+
text += record.assistantMessageEvent.delta ?? "";
|
|
1390
|
+
if (record.type === "agent_settled")
|
|
1391
|
+
settled = true;
|
|
1392
|
+
if (record.type === "response" && record.id === "tool_llm" && record.success === false)
|
|
1393
|
+
throw new Error(record.error ?? "Pi 拒绝了这次请求");
|
|
1394
|
+
if (child.exitCode !== null)
|
|
1395
|
+
throw new Error("模型进程意外退出");
|
|
1396
|
+
}
|
|
1397
|
+
if (!settled)
|
|
1398
|
+
throw new Error(`模型在 ${Math.round(input.timeoutMs / 1000)} 秒内没有返回`);
|
|
1399
|
+
if (!text.trim())
|
|
1400
|
+
throw new Error("模型没有返回任何内容");
|
|
1401
|
+
return text;
|
|
1402
|
+
}
|
|
1403
|
+
finally {
|
|
1404
|
+
if (child.exitCode === null && child.pid !== undefined)
|
|
1405
|
+
await killTree(child.pid);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1363
1408
|
async steer(conversationId, message, images) {
|
|
1364
1409
|
const run = this.runs.get(conversationId);
|
|
1365
1410
|
if (!run || !this.canSteer(conversationId))
|
|
@@ -52,6 +52,8 @@ export interface ServerContext {
|
|
|
52
52
|
execService: ExecService;
|
|
53
53
|
toolRegistry: ToolRegistry;
|
|
54
54
|
toolRunner: ToolRunner;
|
|
55
|
+
/** Absent only if construction failed; a runtime with no Pi reports itself through `available`. */
|
|
56
|
+
toolLlm?: import("./tool-llm.js").ToolLlmService;
|
|
55
57
|
/**
|
|
56
58
|
* Whether the right rail's terminal tab should exist, and why not when it should not.
|
|
57
59
|
*
|
package/dist/server/store.d.ts
CHANGED
|
@@ -419,6 +419,25 @@ export declare class Store {
|
|
|
419
419
|
* claim the same message.
|
|
420
420
|
*/
|
|
421
421
|
claimWaitingMessage(conversationId: string, messageId: string): Promise<Message>;
|
|
422
|
+
/**
|
|
423
|
+
* Puts ONE message into `waiting` when it has no delivery state at all.
|
|
424
|
+
*
|
|
425
|
+
* `releaseWaitingMessage` above moves `sending` BACK to `waiting`, which is a claim being taken back.
|
|
426
|
+
* This is the other direction: a message that was written as an ordinary sent message and has to join
|
|
427
|
+
* the queue after the fact. There are exactly two reasons it happens, and both are consequences of the
|
|
428
|
+
* decision being made before the write rather than after it (see `enqueueAndRun`):
|
|
429
|
+
*
|
|
430
|
+
* 1. Two sends raced. The second one read "idle", wrote itself as an ordinary message, and only then
|
|
431
|
+
* found the run slot taken. Its words are unanswered, so it is a waiting message after all.
|
|
432
|
+
* 2. The preflight that runs between the write and the turn refused (a model that cannot read images,
|
|
433
|
+
* an @-mention that could not be expanded). Nothing reached a model, so the words are still owed an
|
|
434
|
+
* answer and belong in the queue where the user can see them and send them again.
|
|
435
|
+
*
|
|
436
|
+
* Both are restorations of a state that was briefly wrong, never a way to re-queue a message Pi holds:
|
|
437
|
+
* a `sending` message is refused here, which keeps this from becoming a back door around the one-way
|
|
438
|
+
* door `claimWaitingMessage` puts up.
|
|
439
|
+
*/
|
|
440
|
+
markMessageWaiting(conversationId: string, messageId: string): Promise<Message | undefined>;
|
|
422
441
|
/**
|
|
423
442
|
* Puts ONE `sending` message back to `waiting`, for the one case that proves Pi never took it.
|
|
424
443
|
*
|
package/dist/server/store.js
CHANGED
|
@@ -1365,6 +1365,37 @@ export class Store {
|
|
|
1365
1365
|
await this.persistMessages(conversation);
|
|
1366
1366
|
return structuredClone(message);
|
|
1367
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Puts ONE message into `waiting` when it has no delivery state at all.
|
|
1370
|
+
*
|
|
1371
|
+
* `releaseWaitingMessage` above moves `sending` BACK to `waiting`, which is a claim being taken back.
|
|
1372
|
+
* This is the other direction: a message that was written as an ordinary sent message and has to join
|
|
1373
|
+
* the queue after the fact. There are exactly two reasons it happens, and both are consequences of the
|
|
1374
|
+
* decision being made before the write rather than after it (see `enqueueAndRun`):
|
|
1375
|
+
*
|
|
1376
|
+
* 1. Two sends raced. The second one read "idle", wrote itself as an ordinary message, and only then
|
|
1377
|
+
* found the run slot taken. Its words are unanswered, so it is a waiting message after all.
|
|
1378
|
+
* 2. The preflight that runs between the write and the turn refused (a model that cannot read images,
|
|
1379
|
+
* an @-mention that could not be expanded). Nothing reached a model, so the words are still owed an
|
|
1380
|
+
* answer and belong in the queue where the user can see them and send them again.
|
|
1381
|
+
*
|
|
1382
|
+
* Both are restorations of a state that was briefly wrong, never a way to re-queue a message Pi holds:
|
|
1383
|
+
* a `sending` message is refused here, which keeps this from becoming a back door around the one-way
|
|
1384
|
+
* door `claimWaitingMessage` puts up.
|
|
1385
|
+
*/
|
|
1386
|
+
async markMessageWaiting(conversationId, messageId) {
|
|
1387
|
+
const conversation = this.requireConversation(conversationId);
|
|
1388
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
1389
|
+
if (!message || message.role !== "user")
|
|
1390
|
+
return undefined;
|
|
1391
|
+
if (message.delivery === "sending")
|
|
1392
|
+
throw new Error("这条消息已经交给 Agent 了");
|
|
1393
|
+
if (message.delivery === "waiting")
|
|
1394
|
+
return structuredClone(message);
|
|
1395
|
+
message.delivery = "waiting";
|
|
1396
|
+
await this.persistMessages(conversation);
|
|
1397
|
+
return structuredClone(message);
|
|
1398
|
+
}
|
|
1368
1399
|
/**
|
|
1369
1400
|
* Puts ONE `sending` message back to `waiting`, for the one case that proves Pi never took it.
|
|
1370
1401
|
*
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A MOUNTED TOOL ASKING THE MODEL A QUESTION - bookkeeping, ceilings and the consent gate's server half.
|
|
3
|
+
*
|
|
4
|
+
* WHY A TOOL GETS THIS AT ALL. A tool author who wants a model in their tool should not be writing a
|
|
5
|
+
* client: no API key to store, no provider endpoint, no retry, no streaming, no cost ceiling. Pi already
|
|
6
|
+
* holds the credentials and the model configuration, so Super Pi owns the call and the tool asks for it
|
|
7
|
+
* over the same relative API it already uses for its Python jobs.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT IS A COMPLETION AND NOT AN AGENT. `askOnce` spawns Pi with `--no-extensions --no-skills
|
|
10
|
+
* --no-mcp --no-context-files --no-prompt-templates`, so the model answering a tool's prompt has no
|
|
11
|
+
* tools, no files and no history. A tool that could make the agent run things would be handing a
|
|
12
|
+
* sandboxed iframe the user's whole machine, and the iframe is exactly the thing that must not have it.
|
|
13
|
+
*
|
|
14
|
+
* WHY EVERY CEILING IS HERE AND NOT IN THE FRAME. The caller is an opaque sandbox on someone else's
|
|
15
|
+
* machine. Prompt size, answer size, duration, and how many calls may be in flight at once are all
|
|
16
|
+
* decided by this file, because "the tool asked nicely" is not a limit.
|
|
17
|
+
*/
|
|
18
|
+
/** The slice of the runtime this needs, declared structurally for the same reason `KnowledgeRuntime` is. */
|
|
19
|
+
export type ToolLlmRuntime = {
|
|
20
|
+
/** OPTIONAL, exactly as it is on `AgentRuntime`: a runtime with no Pi behind it has none. */
|
|
21
|
+
askOnce?(input: {
|
|
22
|
+
prompt: string;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
cwd: string;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
}): Promise<string>;
|
|
27
|
+
};
|
|
28
|
+
export type ToolLlmStatus = "running" | "succeeded" | "failed" | "cancelled" | "timed_out";
|
|
29
|
+
export type ToolLlmRun = {
|
|
30
|
+
id: string;
|
|
31
|
+
toolId: string;
|
|
32
|
+
status: ToolLlmStatus;
|
|
33
|
+
startedAt: string;
|
|
34
|
+
finishedAt?: string;
|
|
35
|
+
/** The model's answer. Bounded by MAX_TOOL_LLM_RESPONSE_BYTES, and this is the only copy. */
|
|
36
|
+
text: string;
|
|
37
|
+
promptChars: number;
|
|
38
|
+
elapsedMs?: number;
|
|
39
|
+
error?: string;
|
|
40
|
+
};
|
|
41
|
+
export type ToolLlmRequest = {
|
|
42
|
+
toolId: string;
|
|
43
|
+
prompt: string;
|
|
44
|
+
system?: string;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
};
|
|
47
|
+
/** 32KB of prompt is a paragraph of a book; more than that is a tool trying to move a transcript. */
|
|
48
|
+
export declare const MAX_TOOL_LLM_PROMPT_BYTES = 32000;
|
|
49
|
+
/** Answers are clipped rather than refused: a truncated answer is still an answer, and the model may be
|
|
50
|
+
* mid-sentence when the ceiling lands. The clip is visible in `chars` being lower than expected. */
|
|
51
|
+
export declare const MAX_TOOL_LLM_RESPONSE_BYTES = 64000;
|
|
52
|
+
export declare const MAX_TOOL_LLM_TIMEOUT_MS: number;
|
|
53
|
+
export declare class ToolLlmService {
|
|
54
|
+
private readonly runtime;
|
|
55
|
+
private readonly cwd;
|
|
56
|
+
private readonly runs;
|
|
57
|
+
constructor(runtime: ToolLlmRuntime | undefined, cwd: string);
|
|
58
|
+
/** WHY A MISSING RUNTIME IS A SENTENCE AND NOT A 500. The welcome runtime has no Pi behind it, and a
|
|
59
|
+
* tool asking a question there should be told the machine cannot answer it. */
|
|
60
|
+
get available(): boolean;
|
|
61
|
+
start(request: ToolLlmRequest): ToolLlmRun;
|
|
62
|
+
get(id: string, toolId: string): ToolLlmRun | undefined;
|
|
63
|
+
cancel(id: string, toolId: string): ToolLlmRun | undefined;
|
|
64
|
+
wait(id: string, toolId: string, signal?: AbortSignal): Promise<ToolLlmRun>;
|
|
65
|
+
}
|