pi-worker-graph 0.1.0-dev.0
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/LICENSE +21 -0
- package/README.md +455 -0
- package/SECURITY.md +54 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +181 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +81 -0
- package/dist/context.js.map +1 -0
- package/dist/coordination.d.ts +19 -0
- package/dist/coordination.d.ts.map +1 -0
- package/dist/coordination.js +267 -0
- package/dist/coordination.js.map +1 -0
- package/dist/execution-failure.d.ts +42 -0
- package/dist/execution-failure.d.ts.map +1 -0
- package/dist/execution-failure.js +90 -0
- package/dist/execution-failure.js.map +1 -0
- package/dist/extension.d.ts +8 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +641 -0
- package/dist/extension.js.map +1 -0
- package/dist/graph.d.ts +44 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +292 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +9 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +66 -0
- package/dist/json.js.map +1 -0
- package/dist/orchestrator.d.ts +15 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +473 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/output.d.ts +61 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +248 -0
- package/dist/output.js.map +1 -0
- package/dist/pi-subprocess.d.ts +92 -0
- package/dist/pi-subprocess.d.ts.map +1 -0
- package/dist/pi-subprocess.js +897 -0
- package/dist/pi-subprocess.js.map +1 -0
- package/dist/run.d.ts +89 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +562 -0
- package/dist/run.js.map +1 -0
- package/dist/store.d.ts +331 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +1993 -0
- package/dist/store.js.map +1 -0
- package/dist/usage.d.ts +35 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +88 -0
- package/dist/usage.js.map +1 -0
- package/docs/DECISIONS.md +221 -0
- package/docs/DESIGN.md +392 -0
- package/docs/NEXT.md +229 -0
- package/docs/PLAN.md +203 -0
- package/docs/worker-graph.example.json +12 -0
- package/extensions/index.ts +1 -0
- package/extensions/tsconfig.json +11 -0
- package/package.json +66 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { getAgentDir, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { loadWorkerGraphConfiguration } from "./config.js";
|
|
4
|
+
import { registerWorkerCoordinationTools, workerCoordinationContext, } from "./coordination.js";
|
|
5
|
+
import { isRecord } from "./json.js";
|
|
6
|
+
import { registerWorkerGraphOrchestratorTool, WORKER_GRAPH_TOOL_NAME, } from "./orchestrator.js";
|
|
7
|
+
import { NODE_OUTPUT_LIMITS, NODE_OUTPUT_SCHEMA_VERSION, parseNodeArtifact, parseNodeOutput, splitReportArtifact, } from "./output.js";
|
|
8
|
+
import { deleteRun, listRetainedRuns, readRunUsage } from "./store.js";
|
|
9
|
+
const WORKER_ROLE_VARIABLE = "PI_WORKER_GRAPH_ROLE";
|
|
10
|
+
const WORKER_ROLE = "worker";
|
|
11
|
+
const MODE_ENTRY_TYPE = "worker-graph-mode";
|
|
12
|
+
const MODE_STATE_SCHEMA_VERSION = 1;
|
|
13
|
+
const MAX_MODE_TOOL_COUNT = 256;
|
|
14
|
+
const MAX_MODE_NAME_BYTES = 256;
|
|
15
|
+
const DISABLED_PARENT_TOOLS = new Set(["bash", "edit", "write"]);
|
|
16
|
+
const MODE_STATE_FIELDS = new Set([
|
|
17
|
+
"schemaVersion",
|
|
18
|
+
"enabled",
|
|
19
|
+
"toolsBeforeMode",
|
|
20
|
+
"modelBeforeMode",
|
|
21
|
+
"thinkingLevelBeforeMode",
|
|
22
|
+
]);
|
|
23
|
+
const MODE_MODEL_FIELDS = new Set(["provider", "modelId"]);
|
|
24
|
+
const SESSION_THINKING_LEVELS = new Set([
|
|
25
|
+
"minimal",
|
|
26
|
+
"low",
|
|
27
|
+
"medium",
|
|
28
|
+
"high",
|
|
29
|
+
"xhigh",
|
|
30
|
+
"max",
|
|
31
|
+
]);
|
|
32
|
+
const SWARM_USAGE = "Usage: /swarm on|status|off|runs|usage <run-id>|delete <run-id>";
|
|
33
|
+
const SWARM_DELETE_USAGE = "Usage: /swarm delete <run-id>";
|
|
34
|
+
const SWARM_RUN_USAGE_USAGE = "Usage: /swarm usage <run-id>";
|
|
35
|
+
const UNRESTORABLE_TOOLS_MESSAGE = "Worker-graph mode not enabled: the active tool set cannot be restored later";
|
|
36
|
+
const UNRESTORABLE_MODEL_MESSAGE = "Worker-graph mode not enabled: the session's current model cannot be restored later";
|
|
37
|
+
const UNKNOWN_MODEL_MESSAGE = "Worker-graph mode not enabled: the configured orchestrator model is not available";
|
|
38
|
+
const UNAUTHENTICATED_MODEL_MESSAGE = "Worker-graph mode not enabled: the configured orchestrator model has no configured authentication";
|
|
39
|
+
const UNREADABLE_CONFIGURATION_MESSAGE = "Worker-graph mode not enabled: the worker-graph configuration could not be read";
|
|
40
|
+
const MODEL_NOT_RESTORED_MESSAGE = "Worker-graph mode left the session on the orchestrator model: the model it started from is no longer available";
|
|
41
|
+
export const WORKER_REPORT_TOOL_NAME = "worker_graph_report";
|
|
42
|
+
export const SWARM_COMMAND_NAME = "swarm";
|
|
43
|
+
export const SWARM_FLAG_NAME = "swarm";
|
|
44
|
+
function modeToolSnapshot(value) {
|
|
45
|
+
if (!Array.isArray(value) ||
|
|
46
|
+
Object.getPrototypeOf(value) !== Array.prototype ||
|
|
47
|
+
value.length > MAX_MODE_TOOL_COUNT) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const tools = [];
|
|
51
|
+
for (const tool of value) {
|
|
52
|
+
if (typeof tool !== "string" ||
|
|
53
|
+
tool.trim().length === 0 ||
|
|
54
|
+
tool !== tool.trim() ||
|
|
55
|
+
Buffer.byteLength(tool) > MAX_MODE_NAME_BYTES ||
|
|
56
|
+
tool === WORKER_GRAPH_TOOL_NAME ||
|
|
57
|
+
tools.includes(tool)) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
tools.push(tool);
|
|
61
|
+
}
|
|
62
|
+
return Object.freeze(tools);
|
|
63
|
+
}
|
|
64
|
+
function boundedName(value) {
|
|
65
|
+
return (typeof value === "string" &&
|
|
66
|
+
value.trim().length > 0 &&
|
|
67
|
+
value === value.trim() &&
|
|
68
|
+
Buffer.byteLength(value) <= MAX_MODE_NAME_BYTES);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The parent's model is recorded as the two identifiers that find it again,
|
|
72
|
+
* not as Pi's model object: only a provider and an id can be read back through
|
|
73
|
+
* the same bounds after a reload.
|
|
74
|
+
*/
|
|
75
|
+
function modeModelSnapshot(value) {
|
|
76
|
+
if (!isRecord(value) ||
|
|
77
|
+
Object.keys(value).some((field) => !MODE_MODEL_FIELDS.has(field)) ||
|
|
78
|
+
!boundedName(value.provider) ||
|
|
79
|
+
!boundedName(value.modelId)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return Object.freeze({ provider: value.provider, modelId: value.modelId });
|
|
83
|
+
}
|
|
84
|
+
function parseModeState(value) {
|
|
85
|
+
if (!isRecord(value))
|
|
86
|
+
return undefined;
|
|
87
|
+
if (Object.keys(value).some((field) => !MODE_STATE_FIELDS.has(field)) ||
|
|
88
|
+
value.schemaVersion !== MODE_STATE_SCHEMA_VERSION ||
|
|
89
|
+
typeof value.enabled !== "boolean") {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (!value.enabled) {
|
|
93
|
+
return value.toolsBeforeMode === undefined
|
|
94
|
+
? { schemaVersion: MODE_STATE_SCHEMA_VERSION, enabled: false }
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
const toolsBeforeMode = modeToolSnapshot(value.toolsBeforeMode);
|
|
98
|
+
if (toolsBeforeMode === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
// The model and its thinking level were captured together and are restored
|
|
101
|
+
// together, so a record carrying one without the other is not restorable.
|
|
102
|
+
const hasModel = value.modelBeforeMode !== undefined;
|
|
103
|
+
const hasLevel = value.thinkingLevelBeforeMode !== undefined;
|
|
104
|
+
if (hasModel !== hasLevel)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (!hasModel) {
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
schemaVersion: MODE_STATE_SCHEMA_VERSION,
|
|
109
|
+
enabled: true,
|
|
110
|
+
toolsBeforeMode,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const modelBeforeMode = modeModelSnapshot(value.modelBeforeMode);
|
|
114
|
+
if (modelBeforeMode === undefined ||
|
|
115
|
+
typeof value.thinkingLevelBeforeMode !== "string" ||
|
|
116
|
+
!SESSION_THINKING_LEVELS.has(value.thinkingLevelBeforeMode)) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
schemaVersion: MODE_STATE_SCHEMA_VERSION,
|
|
121
|
+
enabled: true,
|
|
122
|
+
toolsBeforeMode,
|
|
123
|
+
modelBeforeMode,
|
|
124
|
+
thinkingLevelBeforeMode: value.thinkingLevelBeforeMode,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function latestModeState(entries) {
|
|
128
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
129
|
+
const entry = entries[index];
|
|
130
|
+
if (isRecord(entry) &&
|
|
131
|
+
entry.type === "custom" &&
|
|
132
|
+
entry.customType === MODE_ENTRY_TYPE) {
|
|
133
|
+
return parseModeState(entry.data);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* `parseNodeOutput` measures every limit in UTF-8 bytes, but JSON Schema can
|
|
140
|
+
* only express `maxLength` in characters. `maxLength` therefore stays a coarse
|
|
141
|
+
* upper bound and the byte limit is stated in the field description, so the
|
|
142
|
+
* caller sees the real contract instead of discovering it through a rejection.
|
|
143
|
+
*/
|
|
144
|
+
const boundedText = (purpose) => Type.String({
|
|
145
|
+
minLength: 1,
|
|
146
|
+
maxLength: NODE_OUTPUT_LIMITS.maxTextBytes,
|
|
147
|
+
description: `${purpose} At most ${NODE_OUTPUT_LIMITS.maxTextBytes} bytes of UTF-8.`,
|
|
148
|
+
});
|
|
149
|
+
const nodeOutputProperties = {
|
|
150
|
+
// Expressed as a bounded integer rather than a literal: a `const` keyword
|
|
151
|
+
// is rejected by some providers' strict function-schema validation.
|
|
152
|
+
schemaVersion: Type.Integer({
|
|
153
|
+
minimum: NODE_OUTPUT_SCHEMA_VERSION,
|
|
154
|
+
maximum: NODE_OUTPUT_SCHEMA_VERSION,
|
|
155
|
+
description: `Report format version. Always ${NODE_OUTPUT_SCHEMA_VERSION}.`,
|
|
156
|
+
}),
|
|
157
|
+
summary: boundedText("What was done, and whether the assignment is met."),
|
|
158
|
+
changedFiles: Type.Array(Type.Object({
|
|
159
|
+
path: Type.String({
|
|
160
|
+
minLength: 1,
|
|
161
|
+
maxLength: NODE_OUTPUT_LIMITS.maxPathBytes,
|
|
162
|
+
description: `Repository-relative path. At most ${NODE_OUTPUT_LIMITS.maxPathBytes} bytes of UTF-8.`,
|
|
163
|
+
}),
|
|
164
|
+
description: boundedText("What changed in this file, and why."),
|
|
165
|
+
}, { additionalProperties: false }), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
|
|
166
|
+
interfaces: Type.Array(boundedText("One interface downstream tasks must build against."), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
|
|
167
|
+
decisions: Type.Array(boundedText("One decision downstream tasks need to know about."), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
|
|
168
|
+
validation: Type.Array(Type.Object({
|
|
169
|
+
command: boundedText("The command that was run."),
|
|
170
|
+
result: boundedText("What the command reported."),
|
|
171
|
+
}, { additionalProperties: false }), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
|
|
172
|
+
blockers: Type.Array(boundedText("One reason the assignment could not be completed."), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* The artifact is a sibling of the report, not a field of it: it is retained
|
|
176
|
+
* under the run for later review and never reaches a dependent task, so it is
|
|
177
|
+
* bounded on its own and leaves the report envelope at schema version 1.
|
|
178
|
+
*/
|
|
179
|
+
const workerReportSchema = Type.Object({
|
|
180
|
+
...nodeOutputProperties,
|
|
181
|
+
artifact: Type.Optional(Type.String({
|
|
182
|
+
minLength: 1,
|
|
183
|
+
maxLength: NODE_OUTPUT_LIMITS.maxArtifactBytes,
|
|
184
|
+
description: [
|
|
185
|
+
"Optional supplemental long-form text retained beside the report:",
|
|
186
|
+
"logs, investigation notes, or detailed review findings.",
|
|
187
|
+
"It is stored under the run for later review and is never given to",
|
|
188
|
+
"dependent tasks, so the structured report above must still stand",
|
|
189
|
+
"on its own.",
|
|
190
|
+
`At most ${NODE_OUTPUT_LIMITS.maxArtifactBytes} bytes of UTF-8.`,
|
|
191
|
+
].join(" "),
|
|
192
|
+
})),
|
|
193
|
+
}, { additionalProperties: false });
|
|
194
|
+
function registerWorkerReportTool(pi) {
|
|
195
|
+
let submitted = false;
|
|
196
|
+
pi.registerTool({
|
|
197
|
+
name: WORKER_REPORT_TOOL_NAME,
|
|
198
|
+
label: "Worker Graph Report",
|
|
199
|
+
description: [
|
|
200
|
+
"Submit the required final structured task report.",
|
|
201
|
+
"Call this exactly once as your final action.",
|
|
202
|
+
`The whole report must serialize to at most ${NODE_OUTPUT_LIMITS.maxBytes} bytes of UTF-8,`,
|
|
203
|
+
"which is a smaller budget than the per-field limits allow together;",
|
|
204
|
+
"a rejected report can be corrected and resubmitted.",
|
|
205
|
+
"Supplemental long-form material belongs in the optional artifact field,",
|
|
206
|
+
"which is retained separately and never shortens the report.",
|
|
207
|
+
].join(" "),
|
|
208
|
+
promptSnippet: "Submit the final worker report and end the task",
|
|
209
|
+
promptGuidelines: [
|
|
210
|
+
"Use worker_graph_report exactly once as the final action after completing or blocking the assigned task.",
|
|
211
|
+
"Report blockers honestly; do not claim success when required work or validation is incomplete.",
|
|
212
|
+
`Keep the whole report within ${NODE_OUTPUT_LIMITS.maxBytes} bytes by summarizing rather than pasting file contents or command transcripts.`,
|
|
213
|
+
"Put logs, investigation notes, and detailed findings in artifact rather than in the report, and never at the cost of a complete report: every blocker, interface, decision, changed file, and validation result belongs in the structured fields, because dependent tasks receive those and never the artifact.",
|
|
214
|
+
],
|
|
215
|
+
parameters: workerReportSchema,
|
|
216
|
+
async execute(_toolCallId, params) {
|
|
217
|
+
if (submitted)
|
|
218
|
+
throw new Error("A final worker report was already submitted");
|
|
219
|
+
// Thrown validation errors reach the model as a tool error, so the
|
|
220
|
+
// message must say what to change. The parent treats a rejected report
|
|
221
|
+
// as recoverable and waits for a corrected resubmission. The artifact
|
|
222
|
+
// is separated before validation, so it is never mistaken for an
|
|
223
|
+
// undeclared report field, and the split is defensive rather than a
|
|
224
|
+
// destructuring: the submitted value is never read through an accessor.
|
|
225
|
+
const parts = splitReportArtifact(params);
|
|
226
|
+
const output = parseNodeOutput(parts.report);
|
|
227
|
+
const artifact = parseNodeArtifact(parts.artifact);
|
|
228
|
+
submitted = true;
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text", text: "Final worker report submitted." }],
|
|
231
|
+
details: {
|
|
232
|
+
kind: "worker-graph-node-output",
|
|
233
|
+
output,
|
|
234
|
+
...(artifact === undefined ? {} : { artifact }),
|
|
235
|
+
},
|
|
236
|
+
terminate: true,
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* One line per unit of retained capacity. A run with no slot, and a slot with
|
|
243
|
+
* no readable run, are both shown rather than omitted: they are the states an
|
|
244
|
+
* operator is reading this to find.
|
|
245
|
+
*/
|
|
246
|
+
function retainedRunsText(runs) {
|
|
247
|
+
if (runs.length === 0)
|
|
248
|
+
return "The run store retains no runs";
|
|
249
|
+
return [
|
|
250
|
+
`The run store retains ${runs.length} run(s), oldest first:`,
|
|
251
|
+
...runs.map((run) => [
|
|
252
|
+
run.runId ?? "(slot names no run)",
|
|
253
|
+
`slot ${run.slot ?? "none"}`,
|
|
254
|
+
run.createdAt ?? "unreadable",
|
|
255
|
+
run.owned ? "owned" : "free",
|
|
256
|
+
].join(" ")),
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Tokens come from the provider's telemetry and are the sturdy number; cost
|
|
261
|
+
* is Pi's pricing of those tokens and only as good as its pricing table, so it
|
|
262
|
+
* is labelled as an estimate. Tasks that ran and recorded nothing are named
|
|
263
|
+
* rather than folded into the total as zero.
|
|
264
|
+
*/
|
|
265
|
+
function runUsageText(usage) {
|
|
266
|
+
const money = (value) => value.toFixed(4);
|
|
267
|
+
return [
|
|
268
|
+
`Run ${usage.runId} spent ${usage.total.totalTokens} token(s) over ${usage.total.turns} turn(s)`,
|
|
269
|
+
` input ${usage.total.input} output ${usage.total.output} cache read ${usage.total.cacheRead} cache write ${usage.total.cacheWrite}`,
|
|
270
|
+
` estimated cost ${money(usage.total.cost.total)} (Pi's pricing of the reported tokens)`,
|
|
271
|
+
...usage.tasks.map((task) => [
|
|
272
|
+
` ${task.taskId}`,
|
|
273
|
+
task.status,
|
|
274
|
+
task.usage === undefined
|
|
275
|
+
? task.accounting === "not_started"
|
|
276
|
+
? "did not run"
|
|
277
|
+
: "no recorded usage"
|
|
278
|
+
: `${task.usage.totalTokens} token(s), ${money(task.usage.cost.total)}`,
|
|
279
|
+
].join(" ")),
|
|
280
|
+
...(usage.unaccounted.length === 0
|
|
281
|
+
? []
|
|
282
|
+
: [
|
|
283
|
+
`The total excludes ${usage.unaccounted.length} task(s) that recorded no usage: ${usage.unaccounted.join(", ")}`,
|
|
284
|
+
]),
|
|
285
|
+
].join("\n");
|
|
286
|
+
}
|
|
287
|
+
function orchestratorDependencies(dependencies) {
|
|
288
|
+
return {
|
|
289
|
+
getAgentDirectory: dependencies.getAgentDirectory ?? getAgentDir,
|
|
290
|
+
...(dependencies.loadConfiguration === undefined
|
|
291
|
+
? {}
|
|
292
|
+
: { loadConfiguration: dependencies.loadConfiguration }),
|
|
293
|
+
...(dependencies.createExecutor === undefined
|
|
294
|
+
? {}
|
|
295
|
+
: { createExecutor: dependencies.createExecutor }),
|
|
296
|
+
...(dependencies.executeGraph === undefined
|
|
297
|
+
? {}
|
|
298
|
+
: { executeGraph: dependencies.executeGraph }),
|
|
299
|
+
...(dependencies.readOutput === undefined
|
|
300
|
+
? {}
|
|
301
|
+
: { readOutput: dependencies.readOutput }),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** Pi package entry point with mutually exclusive worker and parent roles. */
|
|
305
|
+
export default function registerWorkerGraph(pi, dependencies = {}) {
|
|
306
|
+
if (process.env[WORKER_ROLE_VARIABLE] === WORKER_ROLE) {
|
|
307
|
+
registerWorkerReportTool(pi);
|
|
308
|
+
const coordination = workerCoordinationContext();
|
|
309
|
+
if (coordination !== undefined) {
|
|
310
|
+
registerWorkerCoordinationTools(pi, coordination);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
pi.registerFlag(SWARM_FLAG_NAME, {
|
|
315
|
+
description: "Start with worker-graph orchestration enabled",
|
|
316
|
+
type: "boolean",
|
|
317
|
+
default: false,
|
|
318
|
+
});
|
|
319
|
+
const resolved = orchestratorDependencies(dependencies);
|
|
320
|
+
const loadConfiguration = resolved.loadConfiguration ?? loadWorkerGraphConfiguration;
|
|
321
|
+
registerWorkerGraphOrchestratorTool(pi, resolved);
|
|
322
|
+
let enabled = false;
|
|
323
|
+
let toolsBeforeMode;
|
|
324
|
+
let modelBeforeMode;
|
|
325
|
+
let thinkingLevelBeforeMode;
|
|
326
|
+
/** The profile the mode applied, for `/swarm status`. */
|
|
327
|
+
let appliedOrchestrator;
|
|
328
|
+
const refuse = (message) => ({ ok: false, message });
|
|
329
|
+
/**
|
|
330
|
+
* Puts the session back on a recorded model. A model Pi can no longer find,
|
|
331
|
+
* or one whose provider lost its authentication, leaves the session where it
|
|
332
|
+
* is rather than throwing at whoever was leaving the mode.
|
|
333
|
+
*/
|
|
334
|
+
const applyModel = async (ctx, snapshot, level) => {
|
|
335
|
+
const model = ctx.modelRegistry.find(snapshot.provider, snapshot.modelId);
|
|
336
|
+
if (model === undefined || !(await pi.setModel(model)))
|
|
337
|
+
return false;
|
|
338
|
+
pi.setThinkingLevel(level);
|
|
339
|
+
return true;
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* The snapshot is captured through the same bounds that restore it. A tool set
|
|
343
|
+
* this extension could not read back must never enable the mode, because the
|
|
344
|
+
* suppressed parent tools would then be unrecoverable after a reload. The
|
|
345
|
+
* parent's model is held to the same rule: the mode only moves a session onto
|
|
346
|
+
* a configured model once it knows the identifiers that move it back.
|
|
347
|
+
*/
|
|
348
|
+
const activate = async (ctx, restored) => {
|
|
349
|
+
const snapshot = restored?.toolsBeforeMode ??
|
|
350
|
+
toolsBeforeMode ??
|
|
351
|
+
modeToolSnapshot(pi.getActiveTools().filter((name) => name !== WORKER_GRAPH_TOOL_NAME));
|
|
352
|
+
if (snapshot === undefined)
|
|
353
|
+
return refuse(UNRESTORABLE_TOOLS_MESSAGE);
|
|
354
|
+
let restoredModelFailed = false;
|
|
355
|
+
let orchestrator;
|
|
356
|
+
try {
|
|
357
|
+
orchestrator = (await loadConfiguration({
|
|
358
|
+
agentDirectory: resolved.getAgentDirectory(),
|
|
359
|
+
workingDirectory: ctx.cwd,
|
|
360
|
+
})).orchestrator;
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
return refuse(error instanceof Error
|
|
364
|
+
? `Worker-graph mode not enabled: ${error.message}`
|
|
365
|
+
: UNREADABLE_CONFIGURATION_MESSAGE);
|
|
366
|
+
}
|
|
367
|
+
const applyTools = () => {
|
|
368
|
+
pi.setActiveTools([
|
|
369
|
+
...snapshot.filter((name) => !DISABLED_PARENT_TOOLS.has(name)),
|
|
370
|
+
WORKER_GRAPH_TOOL_NAME,
|
|
371
|
+
]);
|
|
372
|
+
};
|
|
373
|
+
if (orchestrator === undefined) {
|
|
374
|
+
/**
|
|
375
|
+
* The configuration named an orchestrator when this branch was recorded
|
|
376
|
+
* and no longer does. The recorded model is the only way back, so it is
|
|
377
|
+
* restored here rather than discarded: dropping it would strand the
|
|
378
|
+
* session on a model the operator has since removed from the file.
|
|
379
|
+
*/
|
|
380
|
+
const stranded = restored?.modelBeforeMode ?? modelBeforeMode;
|
|
381
|
+
const strandedLevel = restored?.thinkingLevelBeforeMode ?? thinkingLevelBeforeMode;
|
|
382
|
+
if (stranded !== undefined && strandedLevel !== undefined) {
|
|
383
|
+
restoredModelFailed = !(await applyModel(ctx, stranded, strandedLevel));
|
|
384
|
+
}
|
|
385
|
+
toolsBeforeMode = snapshot;
|
|
386
|
+
modelBeforeMode = undefined;
|
|
387
|
+
thinkingLevelBeforeMode = undefined;
|
|
388
|
+
appliedOrchestrator = undefined;
|
|
389
|
+
applyTools();
|
|
390
|
+
return {
|
|
391
|
+
ok: true,
|
|
392
|
+
...(restoredModelFailed ? { warning: MODEL_NOT_RESTORED_MESSAGE } : {}),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* A restored branch already recorded the model the operator started from.
|
|
397
|
+
* The live session may by then be running the orchestrator model, so the
|
|
398
|
+
* record is what gets carried forward rather than the current model.
|
|
399
|
+
*/
|
|
400
|
+
let previousModel = restored?.modelBeforeMode ?? modelBeforeMode;
|
|
401
|
+
let previousLevel = restored?.thinkingLevelBeforeMode ?? thinkingLevelBeforeMode;
|
|
402
|
+
if (previousModel === undefined || previousLevel === undefined) {
|
|
403
|
+
const current = ctx.model;
|
|
404
|
+
const level = pi.getThinkingLevel();
|
|
405
|
+
if (current === undefined ||
|
|
406
|
+
!boundedName(current.provider) ||
|
|
407
|
+
!boundedName(current.id) ||
|
|
408
|
+
ctx.modelRegistry.find(current.provider, current.id) === undefined ||
|
|
409
|
+
!SESSION_THINKING_LEVELS.has(level)) {
|
|
410
|
+
return refuse(UNRESTORABLE_MODEL_MESSAGE);
|
|
411
|
+
}
|
|
412
|
+
previousModel = Object.freeze({
|
|
413
|
+
provider: current.provider,
|
|
414
|
+
modelId: current.id,
|
|
415
|
+
});
|
|
416
|
+
previousLevel = level;
|
|
417
|
+
}
|
|
418
|
+
const target = ctx.modelRegistry.find(orchestrator.provider, orchestrator.model);
|
|
419
|
+
if (target === undefined)
|
|
420
|
+
return refuse(UNKNOWN_MODEL_MESSAGE);
|
|
421
|
+
applyTools();
|
|
422
|
+
if (!(await pi.setModel(target))) {
|
|
423
|
+
// Nothing is left half-applied: the tool set goes back before the
|
|
424
|
+
// refusal, because the mode is not being entered.
|
|
425
|
+
pi.setActiveTools([...snapshot]);
|
|
426
|
+
return refuse(UNAUTHENTICATED_MODEL_MESSAGE);
|
|
427
|
+
}
|
|
428
|
+
pi.setThinkingLevel(orchestrator.thinkingLevel);
|
|
429
|
+
toolsBeforeMode = snapshot;
|
|
430
|
+
modelBeforeMode = previousModel;
|
|
431
|
+
thinkingLevelBeforeMode = previousLevel;
|
|
432
|
+
appliedOrchestrator = orchestrator;
|
|
433
|
+
return { ok: true };
|
|
434
|
+
};
|
|
435
|
+
/** True when a recorded model could not be put back. */
|
|
436
|
+
const deactivate = async (ctx) => {
|
|
437
|
+
let failed = false;
|
|
438
|
+
if (modelBeforeMode !== undefined &&
|
|
439
|
+
thinkingLevelBeforeMode !== undefined) {
|
|
440
|
+
failed = !(await applyModel(ctx, modelBeforeMode, thinkingLevelBeforeMode));
|
|
441
|
+
modelBeforeMode = undefined;
|
|
442
|
+
thinkingLevelBeforeMode = undefined;
|
|
443
|
+
}
|
|
444
|
+
appliedOrchestrator = undefined;
|
|
445
|
+
if (toolsBeforeMode !== undefined) {
|
|
446
|
+
pi.setActiveTools([...toolsBeforeMode]);
|
|
447
|
+
toolsBeforeMode = undefined;
|
|
448
|
+
return failed;
|
|
449
|
+
}
|
|
450
|
+
const active = pi.getActiveTools();
|
|
451
|
+
if (active.includes(WORKER_GRAPH_TOOL_NAME)) {
|
|
452
|
+
pi.setActiveTools(active.filter((name) => name !== WORKER_GRAPH_TOOL_NAME));
|
|
453
|
+
}
|
|
454
|
+
return failed;
|
|
455
|
+
};
|
|
456
|
+
const persistModeState = () => {
|
|
457
|
+
pi.appendEntry(MODE_ENTRY_TYPE, {
|
|
458
|
+
schemaVersion: MODE_STATE_SCHEMA_VERSION,
|
|
459
|
+
enabled,
|
|
460
|
+
...(enabled && toolsBeforeMode !== undefined
|
|
461
|
+
? { toolsBeforeMode: [...toolsBeforeMode] }
|
|
462
|
+
: {}),
|
|
463
|
+
...(enabled &&
|
|
464
|
+
modelBeforeMode !== undefined &&
|
|
465
|
+
thinkingLevelBeforeMode !== undefined
|
|
466
|
+
? {
|
|
467
|
+
modelBeforeMode: { ...modelBeforeMode },
|
|
468
|
+
thinkingLevelBeforeMode,
|
|
469
|
+
}
|
|
470
|
+
: {}),
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* The startup flag applies only to the launch that carried it. Navigating the
|
|
475
|
+
* session tree restores what the branch recorded, so `/swarm off` is never
|
|
476
|
+
* undone by the flag that started the session.
|
|
477
|
+
*/
|
|
478
|
+
const restoreModeState = async (ctx, applyStartupFlag) => {
|
|
479
|
+
const restoreFailed = await deactivate(ctx);
|
|
480
|
+
const state = latestModeState(ctx.sessionManager.getBranch());
|
|
481
|
+
const enabledByFlag = applyStartupFlag && pi.getFlag(SWARM_FLAG_NAME) === true;
|
|
482
|
+
const requested = enabledByFlag || state?.enabled === true;
|
|
483
|
+
if (!requested) {
|
|
484
|
+
enabled = false;
|
|
485
|
+
return {
|
|
486
|
+
persist: false,
|
|
487
|
+
...(restoreFailed ? { warning: MODEL_NOT_RESTORED_MESSAGE } : {}),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const result = await activate(ctx, state?.enabled === true ? state : undefined);
|
|
491
|
+
enabled = result.ok;
|
|
492
|
+
const warning = restoreFailed
|
|
493
|
+
? MODEL_NOT_RESTORED_MESSAGE
|
|
494
|
+
: result.ok
|
|
495
|
+
? result.warning
|
|
496
|
+
: undefined;
|
|
497
|
+
return {
|
|
498
|
+
persist: enabled && enabledByFlag && state?.enabled !== true,
|
|
499
|
+
...(result.ok ? {} : { refused: result.message }),
|
|
500
|
+
...(warning === undefined ? {} : { warning }),
|
|
501
|
+
};
|
|
502
|
+
};
|
|
503
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
504
|
+
const restored = await restoreModeState(ctx, true);
|
|
505
|
+
if (restored.persist)
|
|
506
|
+
persistModeState();
|
|
507
|
+
if (restored.warning !== undefined) {
|
|
508
|
+
ctx.ui.notify(restored.warning, "warning");
|
|
509
|
+
}
|
|
510
|
+
if (restored.refused !== undefined) {
|
|
511
|
+
ctx.ui.notify(restored.refused, "error");
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
515
|
+
const restored = await restoreModeState(ctx, false);
|
|
516
|
+
if (restored.warning !== undefined) {
|
|
517
|
+
ctx.ui.notify(restored.warning, "warning");
|
|
518
|
+
}
|
|
519
|
+
if (restored.refused !== undefined) {
|
|
520
|
+
ctx.ui.notify(restored.refused, "error");
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
524
|
+
/**
|
|
525
|
+
* A failed restore is discarded rather than notified here. Pi stops the
|
|
526
|
+
* TUI before it emits this event on the interactive quit path, so a
|
|
527
|
+
* notification would go nowhere. Nothing is lost by staying quiet: the
|
|
528
|
+
* branch still records the model the session started from, so a resumed
|
|
529
|
+
* session carries that record forward and `/swarm off` there restores it,
|
|
530
|
+
* reporting a failure where it can actually be seen.
|
|
531
|
+
*/
|
|
532
|
+
await deactivate(ctx);
|
|
533
|
+
});
|
|
534
|
+
/**
|
|
535
|
+
* Resolved from the same agent directory and the same working directory a
|
|
536
|
+
* graph run resolves it from, so the command and the orchestration tool can
|
|
537
|
+
* never disagree about which store they are acting on.
|
|
538
|
+
*/
|
|
539
|
+
const resolveStateRoot = async (workingDirectory) => {
|
|
540
|
+
const configuration = await loadConfiguration({
|
|
541
|
+
agentDirectory: resolved.getAgentDirectory(),
|
|
542
|
+
workingDirectory,
|
|
543
|
+
});
|
|
544
|
+
return configuration.stateRoot;
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* Retention cleanup is a command rather than a tool. Deleting a run destroys
|
|
548
|
+
* the diagnostic state it was kept for, so it is an operator's act: the
|
|
549
|
+
* parent model exposes one static orchestration tool and no way to reach
|
|
550
|
+
* this. Both subcommands work whether or not the mode is enabled, because
|
|
551
|
+
* the state they report outlives any one session, and a store that cannot
|
|
552
|
+
* be read or changed is reported rather than thrown at the session.
|
|
553
|
+
*/
|
|
554
|
+
const runStore = async (ctx, act) => {
|
|
555
|
+
try {
|
|
556
|
+
ctx.ui.notify(await act(await resolveStateRoot(ctx.cwd)), "info");
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
ctx.ui.notify(error instanceof Error ? error.message : "The run store failed", "error");
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
pi.registerCommand(SWARM_COMMAND_NAME, {
|
|
563
|
+
description: "Enable, disable, or inspect worker-graph orchestration and its run store",
|
|
564
|
+
async handler(args, ctx) {
|
|
565
|
+
const [action = "", ...operands] = args
|
|
566
|
+
.trim()
|
|
567
|
+
.split(/\s+/)
|
|
568
|
+
.filter((word) => word.length > 0);
|
|
569
|
+
// Operands are checked before the store is touched, so a mistyped
|
|
570
|
+
// invocation answers with its usage and not with a store failure.
|
|
571
|
+
if (action === "runs") {
|
|
572
|
+
if (operands.length !== 0) {
|
|
573
|
+
ctx.ui.notify(SWARM_USAGE, "warning");
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
await runStore(ctx, async (stateRoot) => retainedRunsText(await listRetainedRuns(stateRoot)));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (action === "usage") {
|
|
580
|
+
const [runId] = operands;
|
|
581
|
+
if (runId === undefined || operands.length !== 1) {
|
|
582
|
+
ctx.ui.notify(SWARM_RUN_USAGE_USAGE, "warning");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
await runStore(ctx, async (stateRoot) => runUsageText(await readRunUsage(stateRoot, runId)));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (action === "delete") {
|
|
589
|
+
const [runId] = operands;
|
|
590
|
+
if (runId === undefined || operands.length !== 1) {
|
|
591
|
+
ctx.ui.notify(SWARM_DELETE_USAGE, "warning");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
await runStore(ctx, async (stateRoot) => (await deleteRun(stateRoot, runId))
|
|
595
|
+
? `Deleted run ${runId} and released its capacity`
|
|
596
|
+
: `No run ${runId} is retained`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (action === "on") {
|
|
600
|
+
// Re-entering would reload the configuration and could report "not
|
|
601
|
+
// enabled" for a mode that is enabled and applied. The mode is
|
|
602
|
+
// already what was asked for, so say that instead.
|
|
603
|
+
if (enabled) {
|
|
604
|
+
ctx.ui.notify("Worker-graph mode is already enabled", "info");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const activated = await activate(ctx);
|
|
608
|
+
if (!activated.ok) {
|
|
609
|
+
ctx.ui.notify(activated.message, "error");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
enabled = true;
|
|
613
|
+
persistModeState();
|
|
614
|
+
if (activated.warning !== undefined) {
|
|
615
|
+
ctx.ui.notify(activated.warning, "warning");
|
|
616
|
+
}
|
|
617
|
+
ctx.ui.notify("Worker-graph mode enabled", "info");
|
|
618
|
+
}
|
|
619
|
+
else if (action === "off") {
|
|
620
|
+
enabled = false;
|
|
621
|
+
const restoreFailed = await deactivate(ctx);
|
|
622
|
+
persistModeState();
|
|
623
|
+
// Reported before the confirmation: leaving the mode on a model the
|
|
624
|
+
// operator did not choose is the more important half of the outcome.
|
|
625
|
+
if (restoreFailed) {
|
|
626
|
+
ctx.ui.notify(MODEL_NOT_RESTORED_MESSAGE, "warning");
|
|
627
|
+
}
|
|
628
|
+
ctx.ui.notify("Worker-graph mode disabled", "info");
|
|
629
|
+
}
|
|
630
|
+
else if (action === "status") {
|
|
631
|
+
ctx.ui.notify(`Worker-graph mode is ${enabled ? "enabled" : "disabled"}${appliedOrchestrator === undefined
|
|
632
|
+
? ""
|
|
633
|
+
: `; the parent is on ${appliedOrchestrator.provider}/${appliedOrchestrator.model} at ${appliedOrchestrator.thinkingLevel} thinking`}`, "info");
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
ctx.ui.notify(SWARM_USAGE, "warning");
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
//# sourceMappingURL=extension.js.map
|