@evandu/super-pi 0.1.1 → 0.1.3
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-CsbZzuMy.css → index-BhFeQ1E-.css} +1 -1
- package/dist/client/assets/index-D_VwFuME.js +43 -0
- package/dist/client/index.html +2 -2
- package/dist/server/agents.d.ts +20 -1
- package/dist/server/agents.js +92 -18
- package/dist/server/application/catalog-service.d.ts +22 -2
- package/dist/server/application/catalog-service.js +61 -9
- package/dist/server/application/conversation-service.d.ts +3 -2
- package/dist/server/application/conversation-service.js +5 -1
- package/dist/server/application/run-service.d.ts +49 -1
- package/dist/server/application/run-service.js +81 -2
- package/dist/server/application/workspace-service.d.ts +3 -2
- package/dist/server/application/workspace-service.js +5 -1
- package/dist/server/capability.d.ts +89 -0
- package/dist/server/capability.js +136 -0
- package/dist/server/command-handler.js +12 -0
- package/dist/server/http-server.js +20 -0
- package/dist/server/index.js +47 -9
- package/dist/server/runtime.d.ts +6 -2
- package/dist/server/runtime.js +19 -4
- package/dist/server/server-context.d.ts +1 -0
- package/dist/server/store.d.ts +48 -0
- package/dist/server/store.js +160 -1
- package/dist/server/subagent-runs.d.ts +2 -0
- package/dist/server/subagent-runs.js +4 -0
- package/dist/server/subagents.d.ts +3 -2
- package/dist/server/subagents.js +5 -1
- package/dist/server/task-tool.d.ts +21 -0
- package/dist/server/task-tool.js +21 -0
- package/dist/server/types.d.ts +45 -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 +8 -2
- package/dist/client/assets/index-BMQsEdio.js +0 -24
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)
|
|
@@ -1051,6 +1194,22 @@ export class Store {
|
|
|
1051
1194
|
await this.persist();
|
|
1052
1195
|
return { todos: structuredClone(todos), dropped };
|
|
1053
1196
|
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Updates user-configured custom agent discovery directories.
|
|
1199
|
+
* Empty/whitespace paths are ignored, and if no valid paths remain, the key is deleted
|
|
1200
|
+
* to keep state.json clean.
|
|
1201
|
+
*/
|
|
1202
|
+
async setCustomAgentDirs(dirs) {
|
|
1203
|
+
const cleaned = Array.from(new Set(dirs.map((d) => d.trim()).filter(Boolean)));
|
|
1204
|
+
if (cleaned.length) {
|
|
1205
|
+
this.state.customAgentDirs = cleaned;
|
|
1206
|
+
}
|
|
1207
|
+
else {
|
|
1208
|
+
delete this.state.customAgentDirs;
|
|
1209
|
+
}
|
|
1210
|
+
await this.persist();
|
|
1211
|
+
return structuredClone(cleaned);
|
|
1212
|
+
}
|
|
1054
1213
|
/**
|
|
1055
1214
|
* Writes the whole state atomically: a temp file, then a rename over the real one.
|
|
1056
1215
|
*
|
|
@@ -89,6 +89,8 @@ export declare class SubagentRunRecorder {
|
|
|
89
89
|
*/
|
|
90
90
|
private readonly controllers;
|
|
91
91
|
constructor(supervisor: SubagentSupervisor, hooks: SubagentRunHooks);
|
|
92
|
+
/** Updates agent discovery directories on the underlying supervisor. */
|
|
93
|
+
setAgentsDir(dir: string | string[]): void;
|
|
92
94
|
/** Every child still running, optionally narrowed to one conversation. The supervisor owns the pids. */
|
|
93
95
|
liveRuns(conversationId?: string): {
|
|
94
96
|
id: string;
|
|
@@ -44,6 +44,10 @@ export class SubagentRunRecorder {
|
|
|
44
44
|
this.supervisor = supervisor;
|
|
45
45
|
this.hooks = hooks;
|
|
46
46
|
}
|
|
47
|
+
/** Updates agent discovery directories on the underlying supervisor. */
|
|
48
|
+
setAgentsDir(dir) {
|
|
49
|
+
this.supervisor.setAgentsDir?.(dir);
|
|
50
|
+
}
|
|
47
51
|
/** Every child still running, optionally narrowed to one conversation. The supervisor owns the pids. */
|
|
48
52
|
liveRuns(conversationId) {
|
|
49
53
|
return this.supervisor.liveRuns(conversationId);
|
|
@@ -136,7 +136,7 @@ export declare class SubagentCancelled extends Error {
|
|
|
136
136
|
/** Spawns sub-agent children Pi and drains their JSONL RPC streams. */
|
|
137
137
|
export declare class SubagentSupervisor {
|
|
138
138
|
private readonly sessionDir;
|
|
139
|
-
private
|
|
139
|
+
private agents;
|
|
140
140
|
private readonly pi;
|
|
141
141
|
/** Live children by run id, so a caller can name the process it means. Cleared in the run's `finally`. */
|
|
142
142
|
private readonly children;
|
|
@@ -147,7 +147,7 @@ export declare class SubagentSupervisor {
|
|
|
147
147
|
* child's spawn line depend on when it was started.
|
|
148
148
|
*/
|
|
149
149
|
constructor(sessionDir: string, command?: string, agents?: {
|
|
150
|
-
dir: string;
|
|
150
|
+
dir: string | string[];
|
|
151
151
|
promptDir: string;
|
|
152
152
|
});
|
|
153
153
|
/**
|
|
@@ -163,6 +163,7 @@ export declare class SubagentSupervisor {
|
|
|
163
163
|
conversationId: string;
|
|
164
164
|
pid: number | undefined;
|
|
165
165
|
}[];
|
|
166
|
+
setAgentsDir(dir: string | string[]): void;
|
|
166
167
|
/**
|
|
167
168
|
* Runs one sub-agent to completion and returns what it said.
|
|
168
169
|
*
|
package/dist/server/subagents.js
CHANGED
|
@@ -195,6 +195,9 @@ export class SubagentSupervisor {
|
|
|
195
195
|
liveRuns(conversationId) {
|
|
196
196
|
return [...this.children].map(([id, run]) => ({ id, conversationId: run.conversationId, pid: run.child.pid }));
|
|
197
197
|
}
|
|
198
|
+
setAgentsDir(dir) {
|
|
199
|
+
this.agents.dir = dir;
|
|
200
|
+
}
|
|
198
201
|
/**
|
|
199
202
|
* Runs one sub-agent to completion and returns what it said.
|
|
200
203
|
*
|
|
@@ -537,6 +540,7 @@ export class SubagentSupervisor {
|
|
|
537
540
|
const agent = agents.find((item) => item.id === agentId);
|
|
538
541
|
if (agent)
|
|
539
542
|
return agent;
|
|
540
|
-
|
|
543
|
+
const dirLabel = Array.isArray(this.agents.dir) ? this.agents.dir.join(", ") : this.agents.dir;
|
|
544
|
+
throw new Error(`sub-agent ${JSON.stringify(agentId)} is not a subagent definition in ${dirLabel}${problems.length ? ` (${problems.length} file(s) there are not usable agents)` : ""}. A sub-agent is an agents/*.md file with \`mode: subagent\`; a primary agent is chosen by a human and is deliberately not dispatchable as one.`);
|
|
541
545
|
}
|
|
542
546
|
}
|
|
@@ -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
|
*
|
package/dist/server/task-tool.js
CHANGED
|
@@ -61,6 +61,27 @@ export const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
|
|
|
61
61
|
export const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
|
|
62
62
|
/** How many task hops deep this process already is. A primary is `0`. See `DEFAULT_SUBAGENT_DEPTH`. */
|
|
63
63
|
export const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
|
|
64
|
+
/**
|
|
65
|
+
* The name the model calls to hand an ordered plan of sub-agent steps to the server.
|
|
66
|
+
*
|
|
67
|
+
* A THIRD tool rather than an extra parameter on `task`, and the reason is what the server does with the
|
|
68
|
+
* two. `task` is one delegation whose result the model reads back and acts on itself; `workflow` is a
|
|
69
|
+
* plan the server walks, carrying each step's output into the next without the model in the middle. One
|
|
70
|
+
* tool with a `steps` array would make every single delegation carry a shape it cannot use, and would make
|
|
71
|
+
* "dispatch one agent" and "run this pipeline" indistinguishable in the transcript - which is the opposite
|
|
72
|
+
* of what the model needs to choose between them.
|
|
73
|
+
*/
|
|
74
|
+
export const WORKFLOW_TOOL_NAME = "workflow";
|
|
75
|
+
/**
|
|
76
|
+
* The endpoint the `workflow` tool dials. A FIFTH name in the same wire as the four above, written out on
|
|
77
|
+
* both sides for the same reason.
|
|
78
|
+
*
|
|
79
|
+
* A SEPARATE variable rather than a path derived from `TASK_ENDPOINT_ENV`, for the reason
|
|
80
|
+
* `TODO_ENDPOINT_ENV` already states: a Pi can legitimately be started with a dispatch endpoint and no
|
|
81
|
+
* workflow endpoint, and a derived path could not tell that apart from a misconfiguration. The tool then
|
|
82
|
+
* refuses in words instead of posting somewhere that does not exist.
|
|
83
|
+
*/
|
|
84
|
+
export const WORKFLOW_ENDPOINT_ENV = "SUPER_PI_WORKFLOW_ENDPOINT";
|
|
64
85
|
/**
|
|
65
86
|
* The dispatchable roster, in the order it will be injected.
|
|
66
87
|
*
|
package/dist/server/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WorkflowRun } from "./workflow.js";
|
|
1
2
|
export type RunStatus = "idle" | "running" | "completed" | "stopped" | "failed";
|
|
2
3
|
export type MessageRole = "user" | "assistant" | "tool" | "system";
|
|
3
4
|
/**
|
|
@@ -253,6 +254,8 @@ export type AgentDefinition = {
|
|
|
253
254
|
*/
|
|
254
255
|
tools?: Record<string, unknown>;
|
|
255
256
|
prompt: string;
|
|
257
|
+
/** The directory this agent definition was discovered from. */
|
|
258
|
+
sourceDir?: string;
|
|
256
259
|
};
|
|
257
260
|
/**
|
|
258
261
|
* What the picker receives. The prompt body is deliberately NOT sent: it is a whole system prompt,
|
|
@@ -260,6 +263,7 @@ export type AgentDefinition = {
|
|
|
260
263
|
*/
|
|
261
264
|
export type AgentSummary = Omit<AgentDefinition, "prompt" | "toolFlags" | "tools"> & {
|
|
262
265
|
toolSummary: string;
|
|
266
|
+
sourceDir?: string;
|
|
263
267
|
};
|
|
264
268
|
export type AgentCatalog = {
|
|
265
269
|
source: "directory" | "unavailable";
|
|
@@ -270,6 +274,8 @@ export type AgentCatalog = {
|
|
|
270
274
|
message: string;
|
|
271
275
|
}[];
|
|
272
276
|
directory: string;
|
|
277
|
+
/** All searched agent directories in priority order. */
|
|
278
|
+
directories?: string[];
|
|
273
279
|
fetchedAt: string;
|
|
274
280
|
error?: string;
|
|
275
281
|
};
|
|
@@ -477,6 +483,16 @@ export type BackgroundTaskSummary = {
|
|
|
477
483
|
startedAt: string;
|
|
478
484
|
resultBlockId: string;
|
|
479
485
|
};
|
|
486
|
+
/**
|
|
487
|
+
* Orchestrated plans, optional for the same reason `todos` is: a `state.json` written before this
|
|
488
|
+
* feature existed has no `workflows` key, and must read correctly rather than looking like an install
|
|
489
|
+
* that has considered a plan and found none. Absence and an empty list mean the same thing to every
|
|
490
|
+
* reader, so the store deletes the key rather than storing `[]`.
|
|
491
|
+
*
|
|
492
|
+
* The workflow's own types live in `workflow.ts` next to the rules that build them. This is a type-only
|
|
493
|
+
* import and is erased at compile time, so it does not close a runtime cycle with the rules that
|
|
494
|
+
* construct them - the same relationship `SubagentRun` already has with `subagent-runs.ts`.
|
|
495
|
+
*/
|
|
480
496
|
export type AppState = {
|
|
481
497
|
workspaces: Workspace[];
|
|
482
498
|
conversations: Conversation[];
|
|
@@ -484,6 +500,8 @@ export type AppState = {
|
|
|
484
500
|
queuedMessages: QueuedMessage[];
|
|
485
501
|
backgroundTasks: BackgroundTaskSummary[];
|
|
486
502
|
todos?: TodoItem[];
|
|
503
|
+
workflows?: WorkflowRun[];
|
|
504
|
+
customAgentDirs?: string[];
|
|
487
505
|
};
|
|
488
506
|
export type TodoItem = {
|
|
489
507
|
id?: string;
|
|
@@ -790,9 +808,20 @@ export type ClientCommand = {
|
|
|
790
808
|
type: "subagent.stop";
|
|
791
809
|
runId: string;
|
|
792
810
|
}
|
|
811
|
+
/** Stops one orchestrated plan. Distinct from `subagent.stop` because the engine holds the loop and
|
|
812
|
+
* the signal, and stopping only the run would leave the plan marching on to the next step. */
|
|
813
|
+
| {
|
|
814
|
+
type: "workflow.stop";
|
|
815
|
+
workflowId: string;
|
|
816
|
+
}
|
|
793
817
|
/** Asks for the `mode: subagent` half of the agent directory. Answered by `subagent.catalog`. */
|
|
794
818
|
| {
|
|
795
819
|
type: "subagent.catalog.fetch";
|
|
820
|
+
} | {
|
|
821
|
+
type: "agents.setCustomDirs";
|
|
822
|
+
dirs: string[];
|
|
823
|
+
} | {
|
|
824
|
+
type: "agents.rescan";
|
|
796
825
|
} | ({
|
|
797
826
|
type: "extension.ui.answer";
|
|
798
827
|
conversationId: string;
|
|
@@ -905,6 +934,22 @@ export type ServerEvent = {
|
|
|
905
934
|
type: "subagent.event";
|
|
906
935
|
run: SubagentRun;
|
|
907
936
|
}
|
|
937
|
+
/**
|
|
938
|
+
* The current SNAPSHOT of one orchestrated plan, sent on every step transition and nothing else.
|
|
939
|
+
*
|
|
940
|
+
* WHOLE SNAPSHOT, like `subagent.event`, for the same reason: a timeline assembled from missed events
|
|
941
|
+
* is a timeline that can be wrong, and this one has to survive a reload and a reconnect race.
|
|
942
|
+
*
|
|
943
|
+
* ON TRANSITIONS ONLY, never on a stream delta, and that asymmetry is deliberate rather than an
|
|
944
|
+
* oversight. A step's own progress already arrives as `subagent.event` for the run it is executing, so
|
|
945
|
+
* a workflow event on every delta would broadcast the whole plan - every step's output, accumulated -
|
|
946
|
+
* once per TOKEN of one child. That is the one way this feature could make the app feel broken. Two
|
|
947
|
+
* events per step (begin, settle) is what a timeline actually needs; the rest is the run card's job.
|
|
948
|
+
*/
|
|
949
|
+
| {
|
|
950
|
+
type: "workflow.event";
|
|
951
|
+
workflow: WorkflowRun;
|
|
952
|
+
}
|
|
908
953
|
/**
|
|
909
954
|
* Pi's own queue snapshot, forwarded the moment `queue_update` arrives on the child's stdout.
|
|
910
955
|
*
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { SubagentRunRecorder } from "./subagent-runs.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
import type { ModelSelection, ServerEvent } from "./types.js";
|
|
4
|
+
import { type WorkflowPlan, type WorkflowRun } from "./workflow.js";
|
|
5
|
+
/**
|
|
6
|
+
* Walking an orchestrated plan: the half of the feature that DECIDES anything.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS FILE HOLDS NO SPAWNING, NO STREAMING AND NO PROCESS TEARDOWN. Every one of those already
|
|
9
|
+
* exists and is already proved - `subagents.ts` spawns and drains, `subagent-runs.ts` records and
|
|
10
|
+
* publishes. The engine's whole job is to call `dispatch` in the right ORDER and decide what to do when
|
|
11
|
+
* a step does not succeed, and `SubagentRunRecorder.dispatch` is the seam that makes that possible: it
|
|
12
|
+
* ALWAYS RESOLVES with a settled record, including for a dispatch that never started (`subagent-runs.ts:159`).
|
|
13
|
+
* So an engine step is `await` one call, read `status` off the record, and move on. There is no error
|
|
14
|
+
* parsing here, no exception handling around a spawn, and no way for a child to be left running because
|
|
15
|
+
* the thing above it threw - which is the entire reason this feature is small.
|
|
16
|
+
*
|
|
17
|
+
* WHAT THE ENGINE IS NOT ALLOWED TO DO, stated as the two rules it is built around:
|
|
18
|
+
*
|
|
19
|
+
* 1. IT DOES NOT DECIDE WHAT A STEP MEANS. `role` is written through to the run record and read by
|
|
20
|
+
* nobody in this process. A step that says "verify" and a step that says "look at it" execute the same
|
|
21
|
+
* way. The rules about who does what live in `agents/*.md` and in the model that wrote the plan, not
|
|
22
|
+
* in a dispatcher holding a table of role names.
|
|
23
|
+
* 2. IT DOES NOT JUDGE RESULTS. A `verifier` step that completes with 结论:失败 has SUCCEEDED, as far as
|
|
24
|
+
* this loop is concerned, and the plan advances. The verdict lives in the text, and the component that
|
|
25
|
+
* can read the text - the primary agent - is what decides whether to run another round. A server that
|
|
26
|
+
* parsed 通过/失败 would be guessing at a convention `verifier.md` states in prose and `scout.md` and
|
|
27
|
+
* `critic.md` do not have at all, and would have no correct answer when the text said neither.
|
|
28
|
+
*/
|
|
29
|
+
/** What the engine needs from the server around it. Injected, so this file holds no globals. */
|
|
30
|
+
export type WorkflowEngineHooks = {
|
|
31
|
+
store: Store;
|
|
32
|
+
/** Publishes one event to whoever is listening. A plain function, so a test can pass an array. */
|
|
33
|
+
broadcast: (event: ServerEvent) => void;
|
|
34
|
+
};
|
|
35
|
+
/** Everything one plan needs to run. The plan itself arrives already validated and normalized. */
|
|
36
|
+
export type WorkflowRunInput = {
|
|
37
|
+
conversationId: string;
|
|
38
|
+
/** The dispatching conversation's workspace, resolved by the caller. Never defaulted to the server's own directory. */
|
|
39
|
+
cwd: string;
|
|
40
|
+
plan: WorkflowPlan;
|
|
41
|
+
model?: ModelSelection;
|
|
42
|
+
/** Tools the primary agent denied, inherited by every step, exactly as a single dispatch inherits them. */
|
|
43
|
+
denyTools?: readonly string[];
|
|
44
|
+
/** Cancels the whole plan. Each step is dispatched under it, so one signal reaches the loop and the child. */
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
};
|
|
47
|
+
/** The outcome of a plan, as the endpoint reports it. Always produced, never thrown. */
|
|
48
|
+
export type WorkflowOutcome = {
|
|
49
|
+
workflow: WorkflowRun;
|
|
50
|
+
};
|
|
51
|
+
export declare class WorkflowEngine {
|
|
52
|
+
private readonly recorder;
|
|
53
|
+
private readonly hooks;
|
|
54
|
+
/**
|
|
55
|
+
* One controller per in-flight plan.
|
|
56
|
+
*
|
|
57
|
+
* NEEDED SEPARATELY FROM THE RECORDER'S, and the difference is what stops a plan between steps. The
|
|
58
|
+
* recorder's map only holds runs that are executing right now, so a plan halted by a signal while no
|
|
59
|
+
* child is out would have nothing to abort and would happily start the next one. This map holds the
|
|
60
|
+
* PLAN, which exists for its whole length, so `stop` reaches it in every phase.
|
|
61
|
+
*/
|
|
62
|
+
private readonly controllers;
|
|
63
|
+
constructor(recorder: SubagentRunRecorder, hooks: WorkflowEngineHooks);
|
|
64
|
+
/**
|
|
65
|
+
* Stops ONE plan, and says whether there was anything to stop.
|
|
66
|
+
*
|
|
67
|
+
* `false` for an unknown or already-finished id is deliberate and matches `SubagentRunRecorder.stop`:
|
|
68
|
+
* the click that produces it raced the plan's own terminal event, and an error reply would put a
|
|
69
|
+
* failure banner on a timeline that says it finished normally.
|
|
70
|
+
*
|
|
71
|
+
* The abort reaches the child too, because every step is dispatched under this controller's signal and
|
|
72
|
+
* `dispatch` turns it into the same abort-then-killTree the recorder uses. Stopping a plan really does
|
|
73
|
+
* stop the process it is on.
|
|
74
|
+
*/
|
|
75
|
+
stop(workflowId: string): boolean;
|
|
76
|
+
/** Stops every plan in flight for one conversation, and returns how many were signalled. */
|
|
77
|
+
stopConversation(conversationId: string): number;
|
|
78
|
+
/** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
|
|
79
|
+
abortAll(): void;
|
|
80
|
+
/** The conversation a plan belongs to, which only this map knows while the plan is running. */
|
|
81
|
+
private conversationOf;
|
|
82
|
+
/**
|
|
83
|
+
* Which conversation each in-flight plan belongs to.
|
|
84
|
+
*
|
|
85
|
+
* Held beside the controllers rather than read back from the store, because the store is the right
|
|
86
|
+
* place for what a person may read later and the wrong place for a synchronous lookup in a stop
|
|
87
|
+
* handler - it clones the entire state, and this runs on a click.
|
|
88
|
+
*/
|
|
89
|
+
private readonly owners;
|
|
90
|
+
/**
|
|
91
|
+
* Executes one plan to a terminal state, and returns the record as it ended.
|
|
92
|
+
*
|
|
93
|
+
* ALWAYS RESOLVES, like `dispatch` does and for the same reason: by the time the first await runs, the
|
|
94
|
+
* whole plan is already on disk, so the honest account of a plan that cannot proceed is a record that
|
|
95
|
+
* says why, rather than a rejection that leaves it claiming to be running until the next restart.
|
|
96
|
+
*/
|
|
97
|
+
run(input: WorkflowRunInput): Promise<WorkflowOutcome>;
|
|
98
|
+
/**
|
|
99
|
+
* Runs one step until it succeeds, is skipped, or stops the plan. Returns whether the PLAN should halt.
|
|
100
|
+
*
|
|
101
|
+
* The retry is a loop rather than a recursive call so the bound in the policy is the loop's own
|
|
102
|
+
* condition and cannot be exceeded by a bug in the call structure - the one property a retry policy has
|
|
103
|
+
* to have absolutely.
|
|
104
|
+
*/
|
|
105
|
+
private runStep;
|
|
106
|
+
/**
|
|
107
|
+
* Marks every step from `from` onward as stopped, because none of them will run.
|
|
108
|
+
*
|
|
109
|
+
* LEAVING THEM `pending` WOULD BE A LIE IN THE SPECIFIC DIRECTION THAT MATTERS: a browser renders
|
|
110
|
+
* "未开始" for a pending step, so a plan that died after step one of three would show three steps with
|
|
111
|
+
* two still waiting, and a person would reasonably expect the rest to happen. They will not.
|
|
112
|
+
*/
|
|
113
|
+
private haltRemaining;
|
|
114
|
+
/**
|
|
115
|
+
* Persists a plan, THEN announces it.
|
|
116
|
+
*
|
|
117
|
+
* PERSIST FIRST, ANNOUNCE SECOND, and the order is load-bearing here for exactly the reason it is in
|
|
118
|
+
* `SubagentRunRecorder.settle`: a browser that is told a step finished while `state.json` still says it
|
|
119
|
+
* is running will show a timeline a reload immediately contradicts, and the startup reconciliation that
|
|
120
|
+
* would fix it runs on a SERVER restart, not a page refresh.
|
|
121
|
+
*
|
|
122
|
+
* Unlike the recorder, this AWAITS both halves rather than writing fire-and-forget, and that is not an
|
|
123
|
+
* inconsistency. The recorder cannot await: it publishes on every text delta, and a drain loop that
|
|
124
|
+
* flushed to disk per token would throttle the child it is streaming. This publishes twice per step -
|
|
125
|
+
* at most sixteen times for a whole plan - so awaiting costs nothing measurable and buys the strong
|
|
126
|
+
* version of the guarantee.
|
|
127
|
+
*/
|
|
128
|
+
private commit;
|
|
129
|
+
}
|