@evandu/super-pi 0.1.85 → 0.1.86
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 +9 -7
- package/dist/client/assets/index-B3QVoYJY.css +1 -0
- package/dist/client/assets/index-C_giUkvT.js +72 -0
- package/dist/client/index.html +2 -2
- 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/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-C_giUkvT.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B3QVoYJY.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -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
|
*
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
/** 32KB of prompt is a paragraph of a book; more than that is a tool trying to move a transcript. */
|
|
19
|
+
export const MAX_TOOL_LLM_PROMPT_BYTES = 32_000;
|
|
20
|
+
/** Answers are clipped rather than refused: a truncated answer is still an answer, and the model may be
|
|
21
|
+
* mid-sentence when the ceiling lands. The clip is visible in `chars` being lower than expected. */
|
|
22
|
+
export const MAX_TOOL_LLM_RESPONSE_BYTES = 64_000;
|
|
23
|
+
export const MAX_TOOL_LLM_TIMEOUT_MS = 5 * 60_000;
|
|
24
|
+
const DEFAULT_TOOL_LLM_TIMEOUT_MS = 60_000;
|
|
25
|
+
/** A tool that fires a hundred calls in a loop gets refused, loudly, instead of quietly costing money. */
|
|
26
|
+
const MAX_IN_FLIGHT_PER_TOOL = 3;
|
|
27
|
+
const MAX_IN_FLIGHT_TOTAL = 8;
|
|
28
|
+
export class ToolLlmService {
|
|
29
|
+
runtime;
|
|
30
|
+
cwd;
|
|
31
|
+
runs = new Map();
|
|
32
|
+
constructor(runtime, cwd) {
|
|
33
|
+
this.runtime = runtime;
|
|
34
|
+
this.cwd = cwd;
|
|
35
|
+
}
|
|
36
|
+
/** WHY A MISSING RUNTIME IS A SENTENCE AND NOT A 500. The welcome runtime has no Pi behind it, and a
|
|
37
|
+
* tool asking a question there should be told the machine cannot answer it. */
|
|
38
|
+
get available() { return typeof this.runtime?.askOnce === "function"; }
|
|
39
|
+
start(request) {
|
|
40
|
+
if (!this.available)
|
|
41
|
+
throw new Error("这台机器上没有可用的 Pi 运行时,工具无法调用模型。");
|
|
42
|
+
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
|
43
|
+
if (!prompt)
|
|
44
|
+
throw new Error("请提供要问模型的内容。");
|
|
45
|
+
if (Buffer.byteLength(prompt) > MAX_TOOL_LLM_PROMPT_BYTES)
|
|
46
|
+
throw new Error(`提问太长(上限 ${Math.round(MAX_TOOL_LLM_PROMPT_BYTES / 1024)}KB)。`);
|
|
47
|
+
const system = typeof request.system === "string" ? request.system.trim() : "";
|
|
48
|
+
if (system && Buffer.byteLength(system) > MAX_TOOL_LLM_PROMPT_BYTES)
|
|
49
|
+
throw new Error("系统提示太长。");
|
|
50
|
+
const running = [...this.runs.values()].filter((active) => active.run.status === "running");
|
|
51
|
+
if (running.length >= MAX_IN_FLIGHT_TOTAL)
|
|
52
|
+
throw new Error("已经有太多模型请求在排队,请稍后再试。");
|
|
53
|
+
if (running.filter((active) => active.run.toolId === request.toolId).length >= MAX_IN_FLIGHT_PER_TOOL)
|
|
54
|
+
throw new Error("这个工具同时在等的模型请求已经到上限了。");
|
|
55
|
+
const timeoutMs = Number.isFinite(request.timeoutMs) ? Math.max(1_000, Math.min(MAX_TOOL_LLM_TIMEOUT_MS, Math.floor(request.timeoutMs))) : DEFAULT_TOOL_LLM_TIMEOUT_MS;
|
|
56
|
+
const id = `toolllm_${randomId()}`;
|
|
57
|
+
const run = { id, toolId: request.toolId, status: "running", startedAt: new Date().toISOString(), text: "", promptChars: prompt.length };
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
// The deadline is this service's own, not the frame's: a frame that stops polling does not get to keep
|
|
60
|
+
// a Pi process and a model call alive.
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
62
|
+
timer.unref();
|
|
63
|
+
const active = { run, controller, timer };
|
|
64
|
+
this.runs.set(id, active);
|
|
65
|
+
void (async () => {
|
|
66
|
+
try {
|
|
67
|
+
// Narrowed by `available` in `start`, and captured here: the service outlives any single call.
|
|
68
|
+
const ask = this.runtime?.askOnce?.bind(this.runtime);
|
|
69
|
+
if (!ask)
|
|
70
|
+
throw new Error("这台机器上没有可用的 Pi 运行时,工具无法调用模型。");
|
|
71
|
+
const answer = await ask({
|
|
72
|
+
prompt: system ? `${system}\n\n---\n\n${prompt}` : prompt,
|
|
73
|
+
timeoutMs,
|
|
74
|
+
cwd: this.cwd,
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
});
|
|
77
|
+
if (active.run.status !== "running")
|
|
78
|
+
return;
|
|
79
|
+
active.run.text = clip(answer);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (active.run.status !== "running")
|
|
83
|
+
return;
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
active.run.status = controller.signal.aborted && active.run.finishedAt === undefined ? "timed_out" : "failed";
|
|
86
|
+
active.run.error = controller.signal.aborted ? `模型在 ${Math.round(timeoutMs / 1000)} 秒内没有返回` : message;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
if (active.run.status === "running")
|
|
91
|
+
active.run.status = "succeeded";
|
|
92
|
+
active.run.finishedAt ??= new Date().toISOString();
|
|
93
|
+
active.run.elapsedMs = Date.now() - Date.parse(active.run.startedAt);
|
|
94
|
+
// Bounded memory: a tool that made a thousand calls must not leave a thousand answers in the map.
|
|
95
|
+
if (this.runs.size > 64) {
|
|
96
|
+
const oldest = [...this.runs.entries()].filter(([, entry]) => entry.run.status !== "running").sort((a, b) => Date.parse(a[1].run.finishedAt ?? "") - Date.parse(b[1].run.finishedAt ?? "")).slice(0, this.runs.size - 64);
|
|
97
|
+
for (const [key] of oldest)
|
|
98
|
+
this.runs.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
return copy(run);
|
|
103
|
+
}
|
|
104
|
+
get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
|
|
105
|
+
cancel(id, toolId) {
|
|
106
|
+
const active = this.runs.get(id);
|
|
107
|
+
if (!active || active.run.toolId !== toolId)
|
|
108
|
+
return undefined;
|
|
109
|
+
if (active.run.status === "running") {
|
|
110
|
+
active.controller.abort();
|
|
111
|
+
active.run.status = "cancelled";
|
|
112
|
+
active.run.error = "已取消";
|
|
113
|
+
}
|
|
114
|
+
return copy(active.run);
|
|
115
|
+
}
|
|
116
|
+
async wait(id, toolId, signal) {
|
|
117
|
+
const deadline = Date.now() + MAX_TOOL_LLM_TIMEOUT_MS + 5_000;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const run = this.get(id, toolId);
|
|
120
|
+
if (!run)
|
|
121
|
+
throw new Error("这次模型请求不存在或已经过期。");
|
|
122
|
+
if (run.status !== "running")
|
|
123
|
+
return run;
|
|
124
|
+
if (signal?.aborted)
|
|
125
|
+
throw new Error("已取消");
|
|
126
|
+
if (Date.now() > deadline)
|
|
127
|
+
throw new Error("等待模型回答超时。");
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function clip(text) {
|
|
133
|
+
const buffer = Buffer.from(text, "utf8");
|
|
134
|
+
if (buffer.length <= MAX_TOOL_LLM_RESPONSE_BYTES)
|
|
135
|
+
return text;
|
|
136
|
+
return buffer.subarray(0, MAX_TOOL_LLM_RESPONSE_BYTES).toString("utf8");
|
|
137
|
+
}
|
|
138
|
+
function copy(run) { return { ...run }; }
|
|
139
|
+
function randomId() { return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); }
|