@evandu/super-pi 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/super-pi-task/core.ts +748 -485
- package/agents/super-pi-task/index.ts +198 -141
- package/dist/client/assets/index-CHw4ZoY4.js +43 -0
- package/dist/client/assets/{index-CsbZzuMy.css → index-WlIFA_Dt.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +48 -1
- package/dist/server/application/run-service.js +78 -2
- package/dist/server/capability.d.ts +89 -0
- package/dist/server/capability.js +136 -0
- package/dist/server/command-handler.js +4 -0
- package/dist/server/http-server.js +20 -0
- package/dist/server/index.js +25 -2
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/runtime.js +13 -2
- package/dist/server/store.d.ts +42 -0
- package/dist/server/store.js +144 -1
- package/dist/server/task-tool.d.ts +21 -0
- package/dist/server/task-tool.js +21 -0
- package/dist/server/types.d.ts +34 -0
- package/dist/server/workflow-engine.d.ts +129 -0
- package/dist/server/workflow-engine.js +253 -0
- package/dist/server/workflow.d.ts +287 -0
- package/dist/server/workflow.js +387 -0
- package/package.json +5 -1
- package/dist/client/assets/index-BMQsEdio.js +0 -24
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proving that a dispatch request came from THIS server's primary agent, rather than from anything else on
|
|
3
|
+
* the machine that can open a socket to localhost.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS, since the dispatch endpoint already looks like it authenticates. It does not. It
|
|
6
|
+
* validates ANCESTRY - that a claimed `parentRunId` names a real run belonging to the right conversation -
|
|
7
|
+
* and `docs/AGENT_LOOP_DESIGN.md` states the limit in as many words: "The current HTTP boundary validates
|
|
8
|
+
* supplied ancestry, not caller identity. A local process that omits both parentRunId and depth is
|
|
9
|
+
* indistinguishable from a primary caller." That is true, and it was tolerable while the only reachable
|
|
10
|
+
* primitive was a single dispatch. It stops being tolerable the moment an endpoint can trigger a SEQUENCE
|
|
11
|
+
* of dispatches: one unauthenticated POST becomes an unattended pipeline of sub-agents writing to the
|
|
12
|
+
* user's workspace, which is a far larger lever for the same missing check.
|
|
13
|
+
*
|
|
14
|
+
* WHY A CAPABILITY RATHER THAN A SHARED SECRET. `SUPER_PI_ACCESS_TOKEN` already exists and every endpoint
|
|
15
|
+
* already checks it - but it is SERVER-WIDE and optional: unset is the default, and when unset the whole
|
|
16
|
+
* server runs open on loopback, which is a reasonable choice for an app meant to be run by the person
|
|
17
|
+
* sitting at the machine. A dispatch capability is narrower and non-optional in the way that matters: it
|
|
18
|
+
* exists only for the duration of one turn, for one conversation, and only the primary agent's environment
|
|
19
|
+
* ever holds it. `SubagentSupervisor` passes no `env` to a child at all (`subagents.ts:371-380`), so a
|
|
20
|
+
* sub-agent - whose whole threat is that a model controls its inputs and therefore its shell commands -
|
|
21
|
+
* structurally cannot read it back out of its own environment and post it.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THIS DOES NOT CLAIM. It is not defence against a compromised primary agent, which can read its own
|
|
24
|
+
* environment and would simply forward the capability. It is defence against the two neighbours of the
|
|
25
|
+
* primary that are NOT it: an unrelated local process, and a sub-agent that escaped its read-only
|
|
26
|
+
* allowlist. Neither is the primary, and neither is handed the token.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* The environment variable the capability travels in.
|
|
30
|
+
*
|
|
31
|
+
* Named on both sides of the wire rather than imported by the extension, for the reason every one of
|
|
32
|
+
* these names is written out twice: `agents/` is loaded by Pi's module loader and `src/` by tsc, so a
|
|
33
|
+
* relative import across the two would resolve differently under each. `scripts/task-tool.test.mjs`
|
|
34
|
+
* asserts the two copies agree, because a rename on one side would otherwise fail at dispatch time with a
|
|
35
|
+
* message nobody reads.
|
|
36
|
+
*/
|
|
37
|
+
export declare const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
|
|
38
|
+
/**
|
|
39
|
+
* Mints the capability for one turn of one conversation, and returns it for the extension's environment.
|
|
40
|
+
*
|
|
41
|
+
* CALLED AT SPAWN, so the value is decided before the child exists and cannot be changed underneath it.
|
|
42
|
+
* A turn that never reaches the spawn mints nothing, which is correct: there is no process to hand it to.
|
|
43
|
+
*
|
|
44
|
+
* ROTATED EVERY TURN, and that is the property the duration would not give on its own. A token valid for
|
|
45
|
+
* the whole server lifetime would still be a token an unrelated process could not guess - but a token
|
|
46
|
+
* valid only while its holder is alive is a token that stops being a credential the moment the turn ends,
|
|
47
|
+
* so there is no window in which a leaked value is still live.
|
|
48
|
+
*/
|
|
49
|
+
export declare function mintCapability(conversationId: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Forgets a conversation's capability, at the end of its turn.
|
|
52
|
+
*
|
|
53
|
+
* CALLED FROM `answer()`'s `finally`, so it runs on every exit - a turn that answered, a turn that threw,
|
|
54
|
+
* and a turn whose child was killed. A capability that outlived its turn would keep working for a process
|
|
55
|
+
* that no longer exists, which is precisely the shape of a credential with no owner.
|
|
56
|
+
*/
|
|
57
|
+
export declare function releaseCapability(conversationId: string): void;
|
|
58
|
+
/** Forgets every capability. Called on shutdown, so nothing survives the process that minted it. */
|
|
59
|
+
export declare function releaseAllCapabilities(): void;
|
|
60
|
+
/**
|
|
61
|
+
* The capability currently valid for a conversation, or `undefined` when no primary turn is running.
|
|
62
|
+
*
|
|
63
|
+
* Exported for the endpoint's own diagnostics and for a test that needs to assert the lifecycle without
|
|
64
|
+
* reading a primary turn's environment. Not exported as a way to READ a conversation's capability and use
|
|
65
|
+
* it - a caller in this process is already inside the trust boundary it protects.
|
|
66
|
+
*/
|
|
67
|
+
export declare function capabilityFor(conversationId: string): string | undefined;
|
|
68
|
+
/** The outcome of a check, with a message written for whoever the refusal will be shown to. */
|
|
69
|
+
export type CapabilityVerdict = {
|
|
70
|
+
ok: true;
|
|
71
|
+
} | {
|
|
72
|
+
ok: false;
|
|
73
|
+
message: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Checks a presented capability against the one this conversation's turn was given.
|
|
77
|
+
*
|
|
78
|
+
* `timingSafeEqual` rather than `===`, because `===` on a token short-circuits at the first differing byte
|
|
79
|
+
* and turns the comparison into a timing oracle for the value itself. The length check runs first because
|
|
80
|
+
* `timingSafeEqual` throws on mismatched lengths rather than returning false.
|
|
81
|
+
*
|
|
82
|
+
* THE MESSAGE NAMES BOTH HALVES OF THE PROBLEM, because the reader is a model about to retry and there
|
|
83
|
+
* are two genuinely different failures here. A conversation with no live capability is being dispatched
|
|
84
|
+
* from OUTSIDE a turn - the request never came from a primary agent. A conversation with one, and the
|
|
85
|
+
* presented value does not match it, is a turn whose value was not carried through. An extension that
|
|
86
|
+
* silently omitted the header would otherwise be indistinguishable from an attacker, and would keep
|
|
87
|
+
* retrying forever.
|
|
88
|
+
*/
|
|
89
|
+
export declare function verifyCapability(conversationId: string, presented: unknown): CapabilityVerdict;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Proving that a dispatch request came from THIS server's primary agent, rather than from anything else on
|
|
4
|
+
* the machine that can open a socket to localhost.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS, since the dispatch endpoint already looks like it authenticates. It does not. It
|
|
7
|
+
* validates ANCESTRY - that a claimed `parentRunId` names a real run belonging to the right conversation -
|
|
8
|
+
* and `docs/AGENT_LOOP_DESIGN.md` states the limit in as many words: "The current HTTP boundary validates
|
|
9
|
+
* supplied ancestry, not caller identity. A local process that omits both parentRunId and depth is
|
|
10
|
+
* indistinguishable from a primary caller." That is true, and it was tolerable while the only reachable
|
|
11
|
+
* primitive was a single dispatch. It stops being tolerable the moment an endpoint can trigger a SEQUENCE
|
|
12
|
+
* of dispatches: one unauthenticated POST becomes an unattended pipeline of sub-agents writing to the
|
|
13
|
+
* user's workspace, which is a far larger lever for the same missing check.
|
|
14
|
+
*
|
|
15
|
+
* WHY A CAPABILITY RATHER THAN A SHARED SECRET. `SUPER_PI_ACCESS_TOKEN` already exists and every endpoint
|
|
16
|
+
* already checks it - but it is SERVER-WIDE and optional: unset is the default, and when unset the whole
|
|
17
|
+
* server runs open on loopback, which is a reasonable choice for an app meant to be run by the person
|
|
18
|
+
* sitting at the machine. A dispatch capability is narrower and non-optional in the way that matters: it
|
|
19
|
+
* exists only for the duration of one turn, for one conversation, and only the primary agent's environment
|
|
20
|
+
* ever holds it. `SubagentSupervisor` passes no `env` to a child at all (`subagents.ts:371-380`), so a
|
|
21
|
+
* sub-agent - whose whole threat is that a model controls its inputs and therefore its shell commands -
|
|
22
|
+
* structurally cannot read it back out of its own environment and post it.
|
|
23
|
+
*
|
|
24
|
+
* WHAT THIS DOES NOT CLAIM. It is not defence against a compromised primary agent, which can read its own
|
|
25
|
+
* environment and would simply forward the capability. It is defence against the two neighbours of the
|
|
26
|
+
* primary that are NOT it: an unrelated local process, and a sub-agent that escaped its read-only
|
|
27
|
+
* allowlist. Neither is the primary, and neither is handed the token.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The environment variable the capability travels in.
|
|
31
|
+
*
|
|
32
|
+
* Named on both sides of the wire rather than imported by the extension, for the reason every one of
|
|
33
|
+
* these names is written out twice: `agents/` is loaded by Pi's module loader and `src/` by tsc, so a
|
|
34
|
+
* relative import across the two would resolve differently under each. `scripts/task-tool.test.mjs`
|
|
35
|
+
* asserts the two copies agree, because a rename on one side would otherwise fail at dispatch time with a
|
|
36
|
+
* message nobody reads.
|
|
37
|
+
*/
|
|
38
|
+
export const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
|
|
39
|
+
/**
|
|
40
|
+
* The live capability per conversation, while that conversation's primary turn is running.
|
|
41
|
+
*
|
|
42
|
+
* Keyed by CONVERSATION rather than being one global token, so two conversations being driven at once
|
|
43
|
+
* cannot dispatch as each other and so releasing one turn's capability cannot revoke another's. The value
|
|
44
|
+
* is replaced rather than accumulated: a conversation has one primary turn in flight, and a second spawn
|
|
45
|
+
* for the same conversation supersedes the first.
|
|
46
|
+
*/
|
|
47
|
+
const live = new Map();
|
|
48
|
+
/** A fixed capability, for tests. See `seededCapability` for why this does not weaken anything. */
|
|
49
|
+
const SEED = process.env.SUPER_PI_TASK_CAPABILITY_SEED;
|
|
50
|
+
/** Announced once, because a capability that silently stopped rotating is a security change nobody agreed to. */
|
|
51
|
+
let warnedSeed = false;
|
|
52
|
+
/**
|
|
53
|
+
* Mints the capability for one turn of one conversation, and returns it for the extension's environment.
|
|
54
|
+
*
|
|
55
|
+
* CALLED AT SPAWN, so the value is decided before the child exists and cannot be changed underneath it.
|
|
56
|
+
* A turn that never reaches the spawn mints nothing, which is correct: there is no process to hand it to.
|
|
57
|
+
*
|
|
58
|
+
* ROTATED EVERY TURN, and that is the property the duration would not give on its own. A token valid for
|
|
59
|
+
* the whole server lifetime would still be a token an unrelated process could not guess - but a token
|
|
60
|
+
* valid only while its holder is alive is a token that stops being a credential the moment the turn ends,
|
|
61
|
+
* so there is no window in which a leaked value is still live.
|
|
62
|
+
*/
|
|
63
|
+
export function mintCapability(conversationId) {
|
|
64
|
+
if (SEED) {
|
|
65
|
+
if (!warnedSeed) {
|
|
66
|
+
warnedSeed = true;
|
|
67
|
+
console.warn(`Super Pi: SUPER_PI_TASK_CAPABILITY_SEED is set, so every dispatch capability is the same fixed value instead of being minted per turn. This is for tests - set it only on a server nothing else can reach.`);
|
|
68
|
+
}
|
|
69
|
+
live.set(conversationId, SEED);
|
|
70
|
+
return SEED;
|
|
71
|
+
}
|
|
72
|
+
const token = randomUUID();
|
|
73
|
+
live.set(conversationId, token);
|
|
74
|
+
return token;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Forgets a conversation's capability, at the end of its turn.
|
|
78
|
+
*
|
|
79
|
+
* CALLED FROM `answer()`'s `finally`, so it runs on every exit - a turn that answered, a turn that threw,
|
|
80
|
+
* and a turn whose child was killed. A capability that outlived its turn would keep working for a process
|
|
81
|
+
* that no longer exists, which is precisely the shape of a credential with no owner.
|
|
82
|
+
*/
|
|
83
|
+
export function releaseCapability(conversationId) {
|
|
84
|
+
live.delete(conversationId);
|
|
85
|
+
}
|
|
86
|
+
/** Forgets every capability. Called on shutdown, so nothing survives the process that minted it. */
|
|
87
|
+
export function releaseAllCapabilities() {
|
|
88
|
+
live.clear();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The capability currently valid for a conversation, or `undefined` when no primary turn is running.
|
|
92
|
+
*
|
|
93
|
+
* Exported for the endpoint's own diagnostics and for a test that needs to assert the lifecycle without
|
|
94
|
+
* reading a primary turn's environment. Not exported as a way to READ a conversation's capability and use
|
|
95
|
+
* it - a caller in this process is already inside the trust boundary it protects.
|
|
96
|
+
*/
|
|
97
|
+
export function capabilityFor(conversationId) {
|
|
98
|
+
return live.get(conversationId);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Checks a presented capability against the one this conversation's turn was given.
|
|
102
|
+
*
|
|
103
|
+
* `timingSafeEqual` rather than `===`, because `===` on a token short-circuits at the first differing byte
|
|
104
|
+
* and turns the comparison into a timing oracle for the value itself. The length check runs first because
|
|
105
|
+
* `timingSafeEqual` throws on mismatched lengths rather than returning false.
|
|
106
|
+
*
|
|
107
|
+
* THE MESSAGE NAMES BOTH HALVES OF THE PROBLEM, because the reader is a model about to retry and there
|
|
108
|
+
* are two genuinely different failures here. A conversation with no live capability is being dispatched
|
|
109
|
+
* from OUTSIDE a turn - the request never came from a primary agent. A conversation with one, and the
|
|
110
|
+
* presented value does not match it, is a turn whose value was not carried through. An extension that
|
|
111
|
+
* silently omitted the header would otherwise be indistinguishable from an attacker, and would keep
|
|
112
|
+
* retrying forever.
|
|
113
|
+
*/
|
|
114
|
+
export function verifyCapability(conversationId, presented) {
|
|
115
|
+
// A seeded server accepts its fixed value for EVERY conversation, including one with no turn running.
|
|
116
|
+
// That relaxation is exactly the test affordance and nothing else: `SEED` is documented as test-only,
|
|
117
|
+
// and a process that can set it could set `SUPER_PI_ACCESS_TOKEN` instead and already have full control.
|
|
118
|
+
// The comparison below is still real - a wrong value is still refused - so a seeded server still catches
|
|
119
|
+
// a request that simply forgot the capability, which is the mistake a real extension is most likely to make.
|
|
120
|
+
const expected = live.get(conversationId) ?? SEED;
|
|
121
|
+
if (!expected) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
message: `No primary turn is running for conversation ${conversationId}, so there is nothing that could have dispatched this. Dispatch happens from inside a turn: send the tool call during a turn rather than posting to the server directly.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (typeof presented !== "string" || !presented) {
|
|
128
|
+
return { ok: false, message: `This dispatch carries no ${TASK_CAPABILITY_ENV}. The capability is passed from the primary agent's own environment; a request without it did not come from one. Do not retry the same call - it is not a transient failure.` };
|
|
129
|
+
}
|
|
130
|
+
const left = Buffer.from(expected);
|
|
131
|
+
const right = Buffer.from(presented);
|
|
132
|
+
if (left.length !== right.length || !timingSafeEqual(left, right)) {
|
|
133
|
+
return { ok: false, message: `The ${TASK_CAPABILITY_ENV} presented does not match the one this turn was given. It is minted per turn, so a value from an earlier turn - or from another conversation - is not accepted. Do not retry the same call.` };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
@@ -113,6 +113,10 @@ export class CommandHandler {
|
|
|
113
113
|
this.runService.stopSubagent(command.runId);
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
116
|
+
if (command.type === "workflow.stop") {
|
|
117
|
+
this.runService.stopWorkflow(command.workflowId);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
116
120
|
if (command.type === "extension.ui.answer") {
|
|
117
121
|
const answer = command.cancelled
|
|
118
122
|
? { cancelled: true }
|
|
@@ -76,6 +76,26 @@ export function createHttpServer(options) {
|
|
|
76
76
|
}
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
|
+
if (url.pathname === "/api/workflow/run") {
|
|
80
|
+
if (request.method !== "POST" || !isLoopbackAddress(remote)) {
|
|
81
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback POST");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
let raw = "";
|
|
85
|
+
for await (const chunk of request)
|
|
86
|
+
raw += chunk;
|
|
87
|
+
const json = (status, body) => {
|
|
88
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
const result = await runService.runWorkflow(JSON.parse(raw));
|
|
92
|
+
json(result.status, result.body);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
json(400, { error: error instanceof Error ? error.message : "workflow dispatch failed" });
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
79
99
|
if (url.pathname === "/api/todos") {
|
|
80
100
|
if (request.method !== "POST" || !isLoopbackAddress(remote)) {
|
|
81
101
|
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback POST");
|
package/dist/server/index.js
CHANGED
|
@@ -4,8 +4,10 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { defaultStorePath, Store } from "./store.js";
|
|
6
6
|
import { PiRpcRuntime, WelcomeRuntime } from "./runtime.js";
|
|
7
|
+
import { releaseAllCapabilities } from "./capability.js";
|
|
7
8
|
import { SubagentSupervisor } from "./subagents.js";
|
|
8
9
|
import { SubagentRunRecorder } from "./subagent-runs.js";
|
|
10
|
+
import { WorkflowEngine } from "./workflow-engine.js";
|
|
9
11
|
import { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
10
12
|
import { CatalogService } from "./application/catalog-service.js";
|
|
11
13
|
import { RunService } from "./application/run-service.js";
|
|
@@ -30,6 +32,7 @@ const uploadsDir = join(dataDir, "uploads");
|
|
|
30
32
|
await mkdir(uploadsDir, { recursive: true }).catch(() => { });
|
|
31
33
|
const taskEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/subagent/dispatch`;
|
|
32
34
|
const todoEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/todos`;
|
|
35
|
+
const workflowEndpoint = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/workflow/run`;
|
|
33
36
|
const paths = {
|
|
34
37
|
dataDir,
|
|
35
38
|
rootDir,
|
|
@@ -42,7 +45,7 @@ const paths = {
|
|
|
42
45
|
const store = await Store.open(defaultStorePath(dataDir));
|
|
43
46
|
const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
|
|
44
47
|
? new WelcomeRuntime()
|
|
45
|
-
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: agentsDir, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, uploadsDir });
|
|
48
|
+
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: agentsDir, promptDir: agentPromptDir }, { endpoint: taskEndpoint, todoEndpoint, workflowEndpoint, uploadsDir });
|
|
46
49
|
const broadcaster = new WsBroadcaster(() => store.snapshot());
|
|
47
50
|
const extensionUiService = new ExtensionUiService(broadcaster);
|
|
48
51
|
const supervisor = new SubagentSupervisor(join(dataDir, "pi-sessions"), undefined, {
|
|
@@ -58,9 +61,27 @@ const subagents = new SubagentRunRecorder(supervisor, {
|
|
|
58
61
|
},
|
|
59
62
|
host: (conversationId, runId) => extensionUiService.createHost(conversationId, runId),
|
|
60
63
|
});
|
|
64
|
+
/**
|
|
65
|
+
* The orchestration engine: walks a whole plan the primary agent declared, dispatching each step through
|
|
66
|
+
* the recorder above and handing each step's output to the next.
|
|
67
|
+
*
|
|
68
|
+
* Broadcasts `workflow.event` AND the full state, exactly as the recorder does for `subagent.event`. The
|
|
69
|
+
* extra state frame is what puts a plan into `AppState.workflows`, so a browser that reloads mid-plan
|
|
70
|
+
* rebuilds the timeline from the file rather than from the events it happened to receive - the same reason
|
|
71
|
+
* a run's card survives a refresh. It costs nothing in practice because the engine publishes twice per
|
|
72
|
+
* step rather than per delta.
|
|
73
|
+
*/
|
|
74
|
+
const workflows = new WorkflowEngine(subagents, {
|
|
75
|
+
store,
|
|
76
|
+
broadcast: (event) => {
|
|
77
|
+
broadcaster.broadcast(event);
|
|
78
|
+
if (event.type === "workflow.event")
|
|
79
|
+
broadcaster.broadcastState();
|
|
80
|
+
},
|
|
81
|
+
});
|
|
61
82
|
// ------------------------------------------------------------- Application services
|
|
62
83
|
const catalogService = new CatalogService(runtime, broadcaster, agentsDir);
|
|
63
|
-
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService);
|
|
84
|
+
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, agentsDir, workflows);
|
|
64
85
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, agentsDir);
|
|
65
86
|
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, agentsDir);
|
|
66
87
|
const serverContext = {
|
|
@@ -85,6 +106,8 @@ function shutdown(reason) {
|
|
|
85
106
|
shuttingDown = true;
|
|
86
107
|
console.log(`Super Pi is shutting down (${reason}).`);
|
|
87
108
|
runService.abortAll();
|
|
109
|
+
workflows.abortAll();
|
|
110
|
+
releaseAllCapabilities();
|
|
88
111
|
for (const c of store.snapshot().conversations) {
|
|
89
112
|
extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
90
113
|
}
|
package/dist/server/runtime.d.ts
CHANGED
package/dist/server/runtime.js
CHANGED
|
@@ -3,8 +3,9 @@ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { resolve, join } from "node:path";
|
|
5
5
|
import { agentSpawnArgs, readAgents } from "./agents.js";
|
|
6
|
+
import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
|
|
6
7
|
import { resolvePiCommand, shellQuote } from "./pi-command.js";
|
|
7
|
-
import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, rosterPayload, } from "./task-tool.js";
|
|
8
|
+
import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, rosterPayload, } from "./task-tool.js";
|
|
8
9
|
import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
|
|
9
10
|
import { loadConversationImages } from "./upload.js";
|
|
10
11
|
import { describeToolCall, describeToolCallArgs } from "./tool-calls.js";
|
|
@@ -683,6 +684,12 @@ export class PiRpcRuntime {
|
|
|
683
684
|
[TASK_CONVERSATION_ENV]: conversationId,
|
|
684
685
|
[TASK_DEPTH_ENV]: "0",
|
|
685
686
|
[TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
|
|
687
|
+
[WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
|
|
688
|
+
// MINTED HERE, AT SPAWN, rather than read from anywhere: this is the only moment the value is
|
|
689
|
+
// decided, so it cannot change underneath a running child. `SubagentSupervisor` never calls this
|
|
690
|
+
// function, so no sub-agent process is ever handed a capability - which is what makes a sub-agent
|
|
691
|
+
// unable to dispatch, however its allowlist is broken.
|
|
692
|
+
[TASK_CAPABILITY_ENV]: mintCapability(conversationId),
|
|
686
693
|
};
|
|
687
694
|
}
|
|
688
695
|
async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images }) {
|
|
@@ -718,7 +725,7 @@ export class PiRpcRuntime {
|
|
|
718
725
|
// other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
|
|
719
726
|
// `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
|
|
720
727
|
// the other is not.
|
|
721
|
-
const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME] : [] });
|
|
728
|
+
const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME] : [] });
|
|
722
729
|
// Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
|
|
723
730
|
// "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
|
|
724
731
|
if (!this.pi.found)
|
|
@@ -930,6 +937,10 @@ export class PiRpcRuntime {
|
|
|
930
937
|
finally {
|
|
931
938
|
signal.removeEventListener("abort", abort);
|
|
932
939
|
this.runs.delete(conversation.id);
|
|
940
|
+
// The capability dies with the turn that was given it. In this `finally`, so it happens on every
|
|
941
|
+
// exit - a turn that answered, a turn that threw, and a turn whose child was killed - because a
|
|
942
|
+
// credential whose turn has ended is a credential with no owner.
|
|
943
|
+
releaseCapability(conversation.id);
|
|
933
944
|
// A steer still unanswered when the turn ends is REJECTED, not left pending. The drain loop above
|
|
934
945
|
// is the only reader of this child's stdout and it has stopped, so nothing would ever settle that
|
|
935
946
|
// promise: the caller would wait for a timeout to learn something this process already knows. The
|
package/dist/server/store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WorkflowRun } from "./workflow.js";
|
|
1
2
|
import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallBlock, Workspace } from "./types.js";
|
|
2
3
|
/** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
|
|
3
4
|
*
|
|
@@ -31,6 +32,17 @@ export declare function newConversationId(): string;
|
|
|
31
32
|
* re-deriving it.
|
|
32
33
|
*/
|
|
33
34
|
export declare const SUBAGENT_RUN_RETENTION = 100;
|
|
35
|
+
/**
|
|
36
|
+
* How many orchestrated plans are kept.
|
|
37
|
+
*
|
|
38
|
+
* SMALLER THAN THE RUN RETENTION, and deliberately so. A plan does not duplicate the runs it executed -
|
|
39
|
+
* it holds one row per STEP - so fifty plans point at runs that are themselves evicted at 100. Keeping
|
|
40
|
+
* more plans than runs would leave timelines whose cards have all been trimmed away, which is a worse
|
|
41
|
+
* outcome than forgetting the plan: an empty timeline reads as "nothing ran" rather than as "the details
|
|
42
|
+
* aged out". Fifty is also well past what one working day of orchestration produces, since a plan is a
|
|
43
|
+
* request that needed several specialists rather than a unit of work.
|
|
44
|
+
*/
|
|
45
|
+
export declare const WORKFLOW_RETENTION = 50;
|
|
34
46
|
/**
|
|
35
47
|
* Everything a revert decided, before anything is changed.
|
|
36
48
|
*
|
|
@@ -365,6 +377,36 @@ export declare class Store {
|
|
|
365
377
|
* back on disk, and one that trimmed on read would show a browser a roster the file still holds.
|
|
366
378
|
*/
|
|
367
379
|
beginSubagentRun(run: SubagentRun): Promise<SubagentRun>;
|
|
380
|
+
/**
|
|
381
|
+
* Writes a whole plan BEFORE step one runs, and persists it.
|
|
382
|
+
*
|
|
383
|
+
* THE ORDER THIS EXISTS TO ENFORCE. A plan recorded only as it executes leaves a reload showing
|
|
384
|
+
* nothing recoverable - which steps were intended, in what order, under which failure policy - because
|
|
385
|
+
* a transcript of the model asking for a plan is not the plan. Writing it whole first makes the record
|
|
386
|
+
* a CLAIM the engine then makes good, exactly the relationship a run has to its child process, and it
|
|
387
|
+
* means an engine that dies mid-plan leaves a document a person can read instead of a gap.
|
|
388
|
+
*
|
|
389
|
+
* Steps arrive already in their pending form with empty `attempts`, rather than being built as the
|
|
390
|
+
* engine reaches them, so what lands on disk before anything runs is the whole intention.
|
|
391
|
+
*
|
|
392
|
+
* `structuredClone` on the way out, like `beginSubagentRun`: the engine mutates its own copy on every
|
|
393
|
+
* transition, and a caller holding a live reference would watch the record change under itself.
|
|
394
|
+
*/
|
|
395
|
+
beginWorkflow(plan: WorkflowRun): Promise<WorkflowRun>;
|
|
396
|
+
/**
|
|
397
|
+
* Replaces a plan wholesale, and persists it.
|
|
398
|
+
*
|
|
399
|
+
* THE WHOLE PLAN RATHER THAN A FIELD PATCH, and the reason is that a plan's live state is three moving
|
|
400
|
+
* parts at once - `currentIndex`, the status of one step, and an appended attempt - so a patch type
|
|
401
|
+
* narrow enough to be safe for all three is a shape nothing could be typechecked against. Writing the
|
|
402
|
+
* plan also cannot forget a field, which is the failure a hand-maintained patch list invites as the
|
|
403
|
+
* plan grows new parts.
|
|
404
|
+
*
|
|
405
|
+
* The whole array is cloned before the write, for the same reason the recorder keeps its own copy of a
|
|
406
|
+
* run (`subagent-runs.ts:170`): the caller mutates the object it is about to hand back here, and a store
|
|
407
|
+
* that persisted by reference would write out those edits before they were ever announced.
|
|
408
|
+
*/
|
|
409
|
+
setWorkflow(plan: WorkflowRun): Promise<WorkflowRun>;
|
|
368
410
|
setBackgroundTask(summary: AppState["backgroundTasks"][number]): Promise<void>;
|
|
369
411
|
removeBackgroundTask(id: string): Promise<void>;
|
|
370
412
|
/**
|
package/dist/server/store.js
CHANGED
|
@@ -205,6 +205,96 @@ function trimSubagentRuns(runs, conversations) {
|
|
|
205
205
|
}
|
|
206
206
|
runs.splice(0, runs.length, ...runs.filter((run) => !doomed.has(run.id)));
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* How many orchestrated plans are kept.
|
|
210
|
+
*
|
|
211
|
+
* SMALLER THAN THE RUN RETENTION, and deliberately so. A plan does not duplicate the runs it executed -
|
|
212
|
+
* it holds one row per STEP - so fifty plans point at runs that are themselves evicted at 100. Keeping
|
|
213
|
+
* more plans than runs would leave timelines whose cards have all been trimmed away, which is a worse
|
|
214
|
+
* outcome than forgetting the plan: an empty timeline reads as "nothing ran" rather than as "the details
|
|
215
|
+
* aged out". Fifty is also well past what one working day of orchestration produces, since a plan is a
|
|
216
|
+
* request that needed several specialists rather than a unit of work.
|
|
217
|
+
*/
|
|
218
|
+
export const WORKFLOW_RETENTION = 50;
|
|
219
|
+
/**
|
|
220
|
+
* Evicts settled plans down to `WORKFLOW_RETENTION`, newest first.
|
|
221
|
+
*
|
|
222
|
+
* A `running` plan is NEVER evicted, for the reason runs are not: that row is the claim startup
|
|
223
|
+
* reconciliation looks for, and forgetting it would leave a plan that claims to be executing with nothing
|
|
224
|
+
* executing it - the one state that is unreconcilable rather than merely stale.
|
|
225
|
+
*
|
|
226
|
+
* Unlike `trimSubagentRuns` this makes no attempt to favour live conversations. A plan's own steps name
|
|
227
|
+
* the runs they dispatched, so an evicted plan is the one thing that must survive as long as the cards it
|
|
228
|
+
* explains; the flat newest-N bound keeps that simple and keeps the bound honest.
|
|
229
|
+
*/
|
|
230
|
+
function trimWorkflows(workflows) {
|
|
231
|
+
const settled = workflows.filter((plan) => plan?.status !== "running");
|
|
232
|
+
const excess = settled.length - WORKFLOW_RETENTION;
|
|
233
|
+
if (excess <= 0)
|
|
234
|
+
return;
|
|
235
|
+
const doomed = new Set();
|
|
236
|
+
for (const plan of [...settled].reverse()) {
|
|
237
|
+
if (doomed.size >= excess)
|
|
238
|
+
break;
|
|
239
|
+
doomed.add(plan.id);
|
|
240
|
+
}
|
|
241
|
+
workflows.splice(0, workflows.length, ...workflows.filter((plan) => !doomed.has(plan.id)));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Why a plan the previous process left `running` is stopped rather than resumed, in the same spirit and
|
|
245
|
+
* for the same audience as `ORPHANED_RUN_ERROR`.
|
|
246
|
+
*
|
|
247
|
+
* It says the work was NOT finished, not that it failed. The distinction matters to the person reading
|
|
248
|
+
* it: a verifier that never reported is a question left open, while a patcher that reported and was then
|
|
249
|
+
* interrupted is a question already answered. The plan keeps every attempt it made, so what is actually
|
|
250
|
+
* known is still on the timeline underneath this line.
|
|
251
|
+
*/
|
|
252
|
+
const ORPHANED_WORKFLOW_ERROR = "服务器上次退出时这个编排仍在运行;它的步骤已经随进程消失,本次编排被标记为停止,没有自动续跑。";
|
|
253
|
+
/**
|
|
254
|
+
* Startup reconciliation: every plan the previous process left `running` is an orphan.
|
|
255
|
+
*
|
|
256
|
+
* THE SAME ARGUMENT AS `reconcileSubagentRuns`, applied one level up. A plan marked `running` is a claim
|
|
257
|
+
* that a loop is walking it, and no such loop can survive the process that owned it.
|
|
258
|
+
*
|
|
259
|
+
* WHY `stopped` AND NOT `failed`, which is the one place this deliberately differs from a run. A failed
|
|
260
|
+
* step is a verdict the sub-agent reached; nothing here was reached, so calling it failed would report a
|
|
261
|
+
* conclusion that does not exist. `stopped` is the status this project already uses for work that was
|
|
262
|
+
* cut short rather than completed, and it is what the timeline's own stop button produces, so a plan
|
|
263
|
+
* abandoned by a crash looks like the thing it actually is: halted, with its steps' work intact.
|
|
264
|
+
*
|
|
265
|
+
* Steps are NOT rewritten. A step left `running` keeps that status - it is the true account of the child
|
|
266
|
+
* process that is gone - while every step still `pending` becomes `stopped`, because those never started
|
|
267
|
+
* and leaving them `pending` would show a plan as still having work ahead of it that nothing will do.
|
|
268
|
+
*/
|
|
269
|
+
function reconcileWorkflows(state) {
|
|
270
|
+
// LEFT ABSENT WHEN IT WAS ABSENT, which is the opposite of `reconcileSubagentRuns` and deliberate. That
|
|
271
|
+
// one assigns unconditionally because `subagentRuns` is a required field that must be an array. This one
|
|
272
|
+
// is optional, and the project rule for optional lists is the one `setTodos` states: absence and an
|
|
273
|
+
// empty list mean the same thing to every reader, so the key is DELETED rather than stored as `[]`. A
|
|
274
|
+
// server that has never run an orchestration should not leave behind a `workflows: []` claiming it has
|
|
275
|
+
// considered plans and found none - and writing one would mean this sweep marked the file changed and
|
|
276
|
+
// persisted it, for a key that did not need to exist.
|
|
277
|
+
if (!Array.isArray(state.workflows)) {
|
|
278
|
+
// A present-but-not-an-array value is still normalized, because `state.json` is read blind and a
|
|
279
|
+
// hand edit or an older build can leave anything here; ignoring it would let it reach every reader.
|
|
280
|
+
if (state.workflows !== undefined)
|
|
281
|
+
state.workflows = [];
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
const orphaned = [];
|
|
285
|
+
for (const plan of state.workflows) {
|
|
286
|
+
if (!plan || plan.status !== "running")
|
|
287
|
+
continue;
|
|
288
|
+
for (const step of plan.steps ?? []) {
|
|
289
|
+
if (step?.status === "pending")
|
|
290
|
+
step.status = "stopped";
|
|
291
|
+
}
|
|
292
|
+
plan.status = "stopped";
|
|
293
|
+
plan.finishedAt = now();
|
|
294
|
+
orphaned.push(plan);
|
|
295
|
+
}
|
|
296
|
+
return orphaned;
|
|
297
|
+
}
|
|
208
298
|
/**
|
|
209
299
|
* Startup reconciliation for the follow-up queue: an entry the previous process left `sending` goes
|
|
210
300
|
* back to `queued`.
|
|
@@ -350,6 +440,7 @@ export class Store {
|
|
|
350
440
|
// Reconciling them in separate writes would put a file on disk in which one kind of orphan was fixed
|
|
351
441
|
// and the other was not - a half-repaired state that still needs a second restart to finish.
|
|
352
442
|
const orphaned = reconcileSubagentRuns(state);
|
|
443
|
+
const orphanedWorkflows = reconcileWorkflows(state);
|
|
353
444
|
const interrupted = reconcileConversations(state);
|
|
354
445
|
const requeued = reconcileQueuedMessages(state);
|
|
355
446
|
const todos = reconcileTodos(state);
|
|
@@ -357,9 +448,11 @@ export class Store {
|
|
|
357
448
|
// Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
|
|
358
449
|
// is only rewritten by a later mutation would still read `running` to anything that opened the file
|
|
359
450
|
// in the meantime, and would be reconciled a SECOND time on the next restart.
|
|
360
|
-
if (orphaned.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
451
|
+
if (orphaned.length || orphanedWorkflows.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
361
452
|
for (const run of orphaned)
|
|
362
453
|
console.warn(`Super Pi: sub-agent run ${run.id} was still marked running when the last server stopped - it is now failed, because no child process survived it.`);
|
|
454
|
+
for (const plan of orphanedWorkflows)
|
|
455
|
+
console.warn(`Super Pi: workflow ${plan.id} was still marked running when the last server stopped - it is now stopped, because no step can still be executing.`);
|
|
363
456
|
// Announced for the same reason: a conversation that failed while nobody was watching is invisible
|
|
364
457
|
// otherwise, and the user who was watching a spinner when the server died deserves to learn that
|
|
365
458
|
// their run is over rather than guessing from the fact that it stopped moving.
|
|
@@ -988,6 +1081,56 @@ export class Store {
|
|
|
988
1081
|
// store mutates in place on the next delta, or a run's "snapshot" would change under it.
|
|
989
1082
|
return structuredClone(run);
|
|
990
1083
|
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Writes a whole plan BEFORE step one runs, and persists it.
|
|
1086
|
+
*
|
|
1087
|
+
* THE ORDER THIS EXISTS TO ENFORCE. A plan recorded only as it executes leaves a reload showing
|
|
1088
|
+
* nothing recoverable - which steps were intended, in what order, under which failure policy - because
|
|
1089
|
+
* a transcript of the model asking for a plan is not the plan. Writing it whole first makes the record
|
|
1090
|
+
* a CLAIM the engine then makes good, exactly the relationship a run has to its child process, and it
|
|
1091
|
+
* means an engine that dies mid-plan leaves a document a person can read instead of a gap.
|
|
1092
|
+
*
|
|
1093
|
+
* Steps arrive already in their pending form with empty `attempts`, rather than being built as the
|
|
1094
|
+
* engine reaches them, so what lands on disk before anything runs is the whole intention.
|
|
1095
|
+
*
|
|
1096
|
+
* `structuredClone` on the way out, like `beginSubagentRun`: the engine mutates its own copy on every
|
|
1097
|
+
* transition, and a caller holding a live reference would watch the record change under itself.
|
|
1098
|
+
*/
|
|
1099
|
+
async beginWorkflow(plan) {
|
|
1100
|
+
if (!this.state.workflows)
|
|
1101
|
+
this.state.workflows = [];
|
|
1102
|
+
if (this.state.workflows.some((item) => item?.id === plan.id))
|
|
1103
|
+
throw new Error(`Workflow already recorded: ${plan.id}`);
|
|
1104
|
+
this.state.workflows.unshift(plan);
|
|
1105
|
+
trimWorkflows(this.state.workflows);
|
|
1106
|
+
await this.persist();
|
|
1107
|
+
return structuredClone(plan);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Replaces a plan wholesale, and persists it.
|
|
1111
|
+
*
|
|
1112
|
+
* THE WHOLE PLAN RATHER THAN A FIELD PATCH, and the reason is that a plan's live state is three moving
|
|
1113
|
+
* parts at once - `currentIndex`, the status of one step, and an appended attempt - so a patch type
|
|
1114
|
+
* narrow enough to be safe for all three is a shape nothing could be typechecked against. Writing the
|
|
1115
|
+
* plan also cannot forget a field, which is the failure a hand-maintained patch list invites as the
|
|
1116
|
+
* plan grows new parts.
|
|
1117
|
+
*
|
|
1118
|
+
* The whole array is cloned before the write, for the same reason the recorder keeps its own copy of a
|
|
1119
|
+
* run (`subagent-runs.ts:170`): the caller mutates the object it is about to hand back here, and a store
|
|
1120
|
+
* that persisted by reference would write out those edits before they were ever announced.
|
|
1121
|
+
*/
|
|
1122
|
+
async setWorkflow(plan) {
|
|
1123
|
+
if (!this.state.workflows)
|
|
1124
|
+
this.state.workflows = [];
|
|
1125
|
+
const stored = structuredClone(plan);
|
|
1126
|
+
const index = this.state.workflows.findIndex((item) => item?.id === stored.id);
|
|
1127
|
+
if (index < 0)
|
|
1128
|
+
throw new Error(`Workflow not found: ${stored.id}`);
|
|
1129
|
+
this.state.workflows[index] = stored;
|
|
1130
|
+
trimWorkflows(this.state.workflows);
|
|
1131
|
+
await this.persist();
|
|
1132
|
+
return structuredClone(stored);
|
|
1133
|
+
}
|
|
991
1134
|
async setBackgroundTask(summary) {
|
|
992
1135
|
const existing = this.state.backgroundTasks.find((item) => item.id === summary.id);
|
|
993
1136
|
if (existing)
|
|
@@ -62,6 +62,27 @@ export declare const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
|
|
|
62
62
|
export declare const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
|
|
63
63
|
/** How many task hops deep this process already is. A primary is `0`. See `DEFAULT_SUBAGENT_DEPTH`. */
|
|
64
64
|
export declare const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
|
|
65
|
+
/**
|
|
66
|
+
* The name the model calls to hand an ordered plan of sub-agent steps to the server.
|
|
67
|
+
*
|
|
68
|
+
* A THIRD tool rather than an extra parameter on `task`, and the reason is what the server does with the
|
|
69
|
+
* two. `task` is one delegation whose result the model reads back and acts on itself; `workflow` is a
|
|
70
|
+
* plan the server walks, carrying each step's output into the next without the model in the middle. One
|
|
71
|
+
* tool with a `steps` array would make every single delegation carry a shape it cannot use, and would make
|
|
72
|
+
* "dispatch one agent" and "run this pipeline" indistinguishable in the transcript - which is the opposite
|
|
73
|
+
* of what the model needs to choose between them.
|
|
74
|
+
*/
|
|
75
|
+
export declare const WORKFLOW_TOOL_NAME = "workflow";
|
|
76
|
+
/**
|
|
77
|
+
* The endpoint the `workflow` tool dials. A FIFTH name in the same wire as the four above, written out on
|
|
78
|
+
* both sides for the same reason.
|
|
79
|
+
*
|
|
80
|
+
* A SEPARATE variable rather than a path derived from `TASK_ENDPOINT_ENV`, for the reason
|
|
81
|
+
* `TODO_ENDPOINT_ENV` already states: a Pi can legitimately be started with a dispatch endpoint and no
|
|
82
|
+
* workflow endpoint, and a derived path could not tell that apart from a misconfiguration. The tool then
|
|
83
|
+
* refuses in words instead of posting somewhere that does not exist.
|
|
84
|
+
*/
|
|
85
|
+
export declare const WORKFLOW_ENDPOINT_ENV = "SUPER_PI_WORKFLOW_ENDPOINT";
|
|
65
86
|
/**
|
|
66
87
|
* One entry of the roster the model is shown: what it types into `subagent_type`, and when to pick it.
|
|
67
88
|
*
|