@zachwill/pi-orchestrate 0.1.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.
@@ -0,0 +1,66 @@
1
+ import { Cause, Effect, Exit, FiberMap, Scope } from "effect";
2
+
3
+ export type WorkflowDefectHandler = (error: unknown) => void;
4
+
5
+ export interface WorkflowScheduler<Key> {
6
+ /** Starts a workflow immediately, interrupting and replacing the previous workflow at the key. */
7
+ start(
8
+ key: Key,
9
+ workflow: () => Promise<void>,
10
+ onDefect: WorkflowDefectHandler,
11
+ ): void;
12
+ /** Interrupts the current workflow at the key and waits for its fiber to settle. */
13
+ remove(key: Key): Promise<void>;
14
+ /** Interrupts every retained workflow and closes the scheduler scope. */
15
+ close(): Promise<void>;
16
+ }
17
+
18
+ class EffectWorkflowScheduler<Key> implements WorkflowScheduler<Key> {
19
+ private readonly scope = Scope.makeUnsafe("parallel");
20
+ private readonly fibers: FiberMap.FiberMap<Key, void, never>;
21
+ private closePromise: Promise<void> | undefined;
22
+
23
+ constructor() {
24
+ this.fibers = Effect.runSync(
25
+ Scope.provide(this.scope)(FiberMap.make<Key, void, never>()),
26
+ );
27
+ }
28
+
29
+ start(
30
+ key: Key,
31
+ workflow: () => Promise<void>,
32
+ onDefect: WorkflowDefectHandler,
33
+ ): void {
34
+ const supervised = Effect.promise(workflow).pipe(
35
+ Effect.catchCause((cause) => {
36
+ if (!Cause.hasInterruptsOnly(cause)) {
37
+ try {
38
+ onDefect(Cause.squash(cause));
39
+ } catch {
40
+ // Defect reporting must not become another unsupervised defect.
41
+ }
42
+ }
43
+ return Effect.void;
44
+ }),
45
+ );
46
+
47
+ Effect.runSync(
48
+ FiberMap.run(this.fibers, key, supervised, { startImmediately: true }),
49
+ );
50
+ }
51
+
52
+ async remove(key: Key): Promise<void> {
53
+ await Effect.runPromise(FiberMap.remove(this.fibers, key));
54
+ }
55
+
56
+ close(): Promise<void> {
57
+ if (!this.closePromise) {
58
+ this.closePromise = Effect.runPromise(Scope.close(this.scope, Exit.void));
59
+ }
60
+ return this.closePromise;
61
+ }
62
+ }
63
+
64
+ export function createWorkflowScheduler<Key>(): WorkflowScheduler<Key> {
65
+ return new EffectWorkflowScheduler<Key>();
66
+ }
@@ -0,0 +1,559 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ formatSize,
7
+ getAgentDir,
8
+ truncateHead,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import {
12
+ type CatalogDiagnostic,
13
+ type OrchestrateTaskInput,
14
+ type WaveId,
15
+ type WaveRecord,
16
+ type WorkerCatalog,
17
+ type WorkerDefinition,
18
+ type WorkerId,
19
+ type WorkerOutcome,
20
+ type WorkerRecord,
21
+ type WorkerUsage,
22
+ } from "./domain.js";
23
+ import type {
24
+ AbortTarget,
25
+ AcceptedWave,
26
+ CompletedResult,
27
+ CompletedWave,
28
+ OrchestrationContext,
29
+ OrchestratorRuntime,
30
+ RuntimeSnapshot,
31
+ } from "./runtime.js";
32
+
33
+ const STRICT_OBJECT = { additionalProperties: false } as const;
34
+ const MAX_TASKS_PER_WAVE = 12;
35
+
36
+ const taskSchema = Type.Object(
37
+ {
38
+ worker: Type.String(),
39
+ title: Type.String(),
40
+ instructions: Type.String(),
41
+ },
42
+ STRICT_OBJECT,
43
+ );
44
+
45
+ const orchestrateSchema = Type.Object(
46
+ {
47
+ tasks: Type.Array(taskSchema, {
48
+ minItems: 1,
49
+ maxItems: MAX_TASKS_PER_WAVE,
50
+ }),
51
+ },
52
+ STRICT_OBJECT,
53
+ );
54
+
55
+ const statusSchema = Type.Object({}, STRICT_OBJECT);
56
+
57
+ const workerSendSchema = Type.Object(
58
+ {
59
+ worker_id: Type.String({ minLength: 1 }),
60
+ instructions: Type.String(),
61
+ },
62
+ STRICT_OBJECT,
63
+ );
64
+
65
+ const workerAbortSchema = Type.Union([
66
+ Type.Object(
67
+ {
68
+ worker_ids: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
69
+ },
70
+ STRICT_OBJECT,
71
+ ),
72
+ Type.Object(
73
+ {
74
+ wave_id: Type.String({ minLength: 1 }),
75
+ },
76
+ STRICT_OBJECT,
77
+ ),
78
+ Type.Object(
79
+ {
80
+ all: Type.Literal(true),
81
+ },
82
+ STRICT_OBJECT,
83
+ ),
84
+ ]);
85
+
86
+ const workerCloseSchema = Type.Object(
87
+ {
88
+ worker_id: Type.String({ minLength: 1 }),
89
+ },
90
+ STRICT_OBJECT,
91
+ );
92
+
93
+ export interface OrchestrationToolDependencies {
94
+ readonly runtime: OrchestratorRuntime;
95
+ getCatalog(ctx: ExtensionContext): WorkerCatalog | Promise<WorkerCatalog>;
96
+ getDispatchMode(toolCallId: string): "async" | "inline";
97
+ }
98
+
99
+ export function registerOrchestrationTools(
100
+ pi: ExtensionAPI,
101
+ deps: OrchestrationToolDependencies,
102
+ ): void {
103
+ pi.registerTool({
104
+ name: "orchestrate",
105
+ label: "Orchestrate",
106
+ description:
107
+ "Dispatch 1 to 12 independent, fully briefed tasks as one concurrent wave. A sole tool call runs asynchronously; sibling tool calls make it inline and blocking.",
108
+ promptSnippet: "Dispatch one concurrent wave of fully briefed worker tasks",
109
+ promptGuidelines: [
110
+ "Use orchestrate for one independent worker wave, with a complete brief for every task.",
111
+ ],
112
+ parameters: orchestrateSchema,
113
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
114
+ const mode = deps.getDispatchMode(toolCallId);
115
+ const runtimeContext = await buildRuntimeContext(ctx, deps);
116
+ const wave = await orchestrateWithMode(
117
+ deps.runtime,
118
+ runtimeContext,
119
+ params.tasks,
120
+ mode,
121
+ signal,
122
+ );
123
+
124
+ if (mode === "async") {
125
+ const acceptedWave = wave as AcceptedWave;
126
+ const readable = acceptedWaveDetails(acceptedWave);
127
+ return {
128
+ content: [
129
+ {
130
+ type: "text",
131
+ text: readableDetails(`Accepted async wave ${readable.wave_id}.`, readable),
132
+ },
133
+ ],
134
+ details: acceptedWave,
135
+ terminate: true,
136
+ };
137
+ }
138
+
139
+ const completedWave = wave as CompletedWave;
140
+ const readable = completedWaveDetails(completedWave);
141
+ return {
142
+ content: [
143
+ {
144
+ type: "text",
145
+ text: readableDetails(
146
+ `Completed inline wave ${readable.wave_id} with ${readable.results.length} result(s).`,
147
+ readable,
148
+ ),
149
+ },
150
+ ],
151
+ details: completedWave,
152
+ };
153
+ },
154
+ });
155
+
156
+ pi.registerTool({
157
+ name: "orchestration_status",
158
+ label: "Orchestration Status",
159
+ description:
160
+ "Diagnostics and recovery only: inspect trusted catalog entries, catalog diagnostics, and this session's runtime state. Never poll for completion.",
161
+ promptSnippet: "Inspect owned orchestration state for diagnostics or recovery",
162
+ promptGuidelines: [
163
+ "Use orchestration_status only for diagnostics or recovery; never poll it for completion.",
164
+ ],
165
+ parameters: statusSchema,
166
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
167
+ const ownerSessionId = requireNonblank(
168
+ "owner session ID",
169
+ ctx.sessionManager.getSessionId(),
170
+ );
171
+ const [catalog, snapshot] = await Promise.all([
172
+ deps.getCatalog(ctx),
173
+ deps.runtime.snapshot(ownerSessionId),
174
+ ]);
175
+ const readable = statusDetails(catalog, snapshot);
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text",
180
+ text: readableDetails("Orchestration diagnostics and recovery snapshot.", readable),
181
+ },
182
+ ],
183
+ details: readable,
184
+ };
185
+ },
186
+ });
187
+
188
+ pi.registerTool({
189
+ name: "worker_send",
190
+ label: "Worker Send",
191
+ description:
192
+ "Send follow-up instructions to an owned ready reusable worker. A sole tool call runs asynchronously; sibling tool calls make it inline and blocking.",
193
+ promptSnippet: "Send follow-up work to an owned ready reusable worker",
194
+ promptGuidelines: [
195
+ "Use worker_send only for follow-up work on an owned ready reusable worker.",
196
+ ],
197
+ parameters: workerSendSchema,
198
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
199
+ const workerId = asWorkerId(params.worker_id);
200
+ const mode = deps.getDispatchMode(toolCallId);
201
+ const runtimeContext = await buildRuntimeContext(ctx, deps);
202
+ const wave = await sendWithMode(
203
+ deps.runtime,
204
+ runtimeContext,
205
+ workerId,
206
+ params.instructions,
207
+ mode,
208
+ signal,
209
+ );
210
+
211
+ if (mode === "async") {
212
+ const acceptedWave = wave as AcceptedWave;
213
+ const readable = acceptedWaveDetails(acceptedWave);
214
+ return {
215
+ content: [
216
+ {
217
+ type: "text",
218
+ text: readableDetails(`Accepted async wave ${readable.wave_id}.`, readable),
219
+ },
220
+ ],
221
+ details: acceptedWave,
222
+ terminate: true,
223
+ };
224
+ }
225
+
226
+ const completedWave = wave as CompletedWave;
227
+ const readable = completedWaveDetails(completedWave);
228
+ return {
229
+ content: [
230
+ {
231
+ type: "text",
232
+ text: readableDetails(
233
+ `Completed inline wave ${readable.wave_id} with ${readable.results.length} result(s).`,
234
+ readable,
235
+ ),
236
+ },
237
+ ],
238
+ details: completedWave,
239
+ };
240
+ },
241
+ });
242
+
243
+ pi.registerTool({
244
+ name: "worker_abort",
245
+ label: "Worker Abort",
246
+ description:
247
+ "Abort owned active work by worker IDs, wave ID, or all active owned workers. Use worker_close for ready reusable workers.",
248
+ promptSnippet: "Abort active owned workers by worker IDs, wave ID, or all",
249
+ promptGuidelines: [
250
+ "Use worker_abort only for active work; use worker_close for a ready reusable worker.",
251
+ ],
252
+ parameters: workerAbortSchema,
253
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
254
+ const ownerSessionId = requireNonblank(
255
+ "owner session ID",
256
+ ctx.sessionManager.getSessionId(),
257
+ );
258
+ const target = abortTarget(params);
259
+ await deps.runtime.abort(ownerSessionId, target.runtime);
260
+ const readable = { target: target.external };
261
+ return {
262
+ content: [
263
+ {
264
+ type: "text",
265
+ text: readableDetails("Abort request completed.", readable),
266
+ },
267
+ ],
268
+ details: { target: target.runtime },
269
+ };
270
+ },
271
+ });
272
+
273
+ pi.registerTool({
274
+ name: "worker_close",
275
+ label: "Worker Close",
276
+ description: "Close an owned ready reusable worker that no longer needs follow-up work.",
277
+ promptSnippet: "Close an owned ready reusable worker",
278
+ promptGuidelines: [
279
+ "Use worker_close when an owned ready reusable worker is finished.",
280
+ ],
281
+ parameters: workerCloseSchema,
282
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
283
+ const ownerSessionId = requireNonblank(
284
+ "owner session ID",
285
+ ctx.sessionManager.getSessionId(),
286
+ );
287
+ const workerId = asWorkerId(params.worker_id);
288
+ await deps.runtime.close(ownerSessionId, workerId);
289
+ const readable = { worker_id: workerId };
290
+ return {
291
+ content: [
292
+ {
293
+ type: "text",
294
+ text: readableDetails(`Closed worker ${workerId}.`, readable),
295
+ },
296
+ ],
297
+ details: { workerId },
298
+ };
299
+ },
300
+ });
301
+ }
302
+
303
+ async function buildRuntimeContext(
304
+ ctx: ExtensionContext,
305
+ deps: OrchestrationToolDependencies,
306
+ ): Promise<OrchestrationContext> {
307
+ return {
308
+ ownerSessionId: requireNonblank(
309
+ "owner session ID",
310
+ ctx.sessionManager.getSessionId(),
311
+ ),
312
+ cwd: ctx.cwd,
313
+ agentDir: getAgentDir(),
314
+ parentSessionFile: ctx.sessionManager.getSessionFile(),
315
+ projectTrusted: ctx.isProjectTrusted(),
316
+ catalog: await deps.getCatalog(ctx),
317
+ parentModel: ctx.model,
318
+ modelRegistry: ctx.modelRegistry,
319
+ };
320
+ }
321
+
322
+ function orchestrateWithMode(
323
+ runtime: OrchestratorRuntime,
324
+ context: OrchestrationContext,
325
+ tasks: readonly OrchestrateTaskInput[],
326
+ mode: "async" | "inline",
327
+ signal: AbortSignal | undefined,
328
+ ): Promise<AcceptedWave | CompletedWave> {
329
+ if (mode === "async") return runtime.orchestrate(context, tasks, "async");
330
+
331
+ const orchestrateInline = runtime.orchestrate as unknown as (
332
+ context: OrchestrationContext,
333
+ tasks: readonly OrchestrateTaskInput[],
334
+ mode: "inline",
335
+ signal?: AbortSignal,
336
+ ) => Promise<CompletedWave>;
337
+ return orchestrateInline.call(runtime, context, tasks, "inline", signal);
338
+ }
339
+
340
+ function sendWithMode(
341
+ runtime: OrchestratorRuntime,
342
+ context: OrchestrationContext,
343
+ workerId: WorkerId,
344
+ instructions: string,
345
+ mode: "async" | "inline",
346
+ signal: AbortSignal | undefined,
347
+ ): Promise<AcceptedWave | CompletedWave> {
348
+ if (mode === "async") return runtime.send(context, workerId, instructions, "async");
349
+
350
+ const sendInline = runtime.send as unknown as (
351
+ context: OrchestrationContext,
352
+ workerId: WorkerId,
353
+ instructions: string,
354
+ mode: "inline",
355
+ signal?: AbortSignal,
356
+ ) => Promise<CompletedWave>;
357
+ return sendInline.call(runtime, context, workerId, instructions, "inline", signal);
358
+ }
359
+
360
+ function requireNonblank(name: string, value: string): string {
361
+ if (typeof value !== "string" || value.trim() === "") {
362
+ throw new Error(`${name} must not be blank`);
363
+ }
364
+ return value;
365
+ }
366
+
367
+ function asWorkerId(value: string): WorkerId {
368
+ return requireNonblank("worker_id", value) as WorkerId;
369
+ }
370
+
371
+ function asWaveId(value: string): WaveId {
372
+ return requireNonblank("wave_id", value) as WaveId;
373
+ }
374
+
375
+ function abortTarget(params: {
376
+ worker_ids?: string[];
377
+ wave_id?: string;
378
+ all?: true;
379
+ }): {
380
+ runtime: AbortTarget;
381
+ external:
382
+ | { worker_ids: readonly WorkerId[] }
383
+ | { wave_id: WaveId }
384
+ | { all: true };
385
+ } {
386
+ const selectedTargetCount = [
387
+ params.worker_ids !== undefined,
388
+ params.wave_id !== undefined,
389
+ params.all !== undefined,
390
+ ].filter(Boolean).length;
391
+ if (selectedTargetCount !== 1 || (params.all !== undefined && params.all !== true)) {
392
+ throw new Error("Abort target must specify exactly one target");
393
+ }
394
+
395
+ if (params.worker_ids !== undefined) {
396
+ if (!Array.isArray(params.worker_ids) || params.worker_ids.length === 0) {
397
+ throw new Error("worker_ids must contain at least one worker ID");
398
+ }
399
+ const workerIds = params.worker_ids.map(asWorkerId);
400
+ return {
401
+ runtime: { workerIds },
402
+ external: { worker_ids: workerIds },
403
+ };
404
+ }
405
+ if (params.wave_id !== undefined) {
406
+ const waveId = asWaveId(params.wave_id);
407
+ return {
408
+ runtime: { waveId },
409
+ external: { wave_id: waveId },
410
+ };
411
+ }
412
+ return {
413
+ runtime: { all: true },
414
+ external: { all: true },
415
+ };
416
+ }
417
+
418
+ function acceptedWaveDetails(wave: AcceptedWave) {
419
+ return {
420
+ mode: "async" as const,
421
+ wave_id: wave.id,
422
+ worker_ids: [...wave.workerIds],
423
+ };
424
+ }
425
+
426
+ function completedWaveDetails(wave: CompletedWave) {
427
+ return {
428
+ mode: wave.mode,
429
+ wave_id: wave.id,
430
+ owner_session_id: wave.ownerSessionId,
431
+ results: wave.results.map(completedResultDetails),
432
+ };
433
+ }
434
+
435
+ function completedResultDetails(result: CompletedResult) {
436
+ return {
437
+ worker_id: result.workerId,
438
+ worker: result.worker,
439
+ title: result.title,
440
+ status: result.status,
441
+ outcome: outcomeDetails(result.outcome),
442
+ usage: usageDetails(result.usage),
443
+ session_file: result.sessionFile,
444
+ };
445
+ }
446
+
447
+ function statusDetails(catalog: WorkerCatalog, snapshot: RuntimeSnapshot) {
448
+ return {
449
+ catalog: {
450
+ workers: catalog.workers.map(catalogWorkerDetails),
451
+ diagnostics: catalog.diagnostics.map(diagnosticDetails),
452
+ },
453
+ snapshot: {
454
+ waves: snapshot.waves.map(waveDetails),
455
+ workers: snapshot.workers.map(workerDetails),
456
+ },
457
+ };
458
+ }
459
+
460
+ function catalogWorkerDetails(worker: WorkerDefinition) {
461
+ return {
462
+ name: worker.name,
463
+ description: worker.description,
464
+ lifecycle: worker.lifecycle,
465
+ source: {
466
+ kind: worker.source.kind,
467
+ file_path: worker.source.filePath,
468
+ },
469
+ tools: [...worker.tools],
470
+ skills: [...worker.skills],
471
+ model: worker.model
472
+ ? { provider: worker.model.provider, model_id: worker.model.modelId }
473
+ : undefined,
474
+ thinking: worker.thinking,
475
+ compaction: worker.compaction
476
+ ? {
477
+ enabled: worker.compaction.enabled,
478
+ reserve_tokens: worker.compaction.reserveTokens,
479
+ keep_recent_tokens: worker.compaction.keepRecentTokens,
480
+ }
481
+ : undefined,
482
+ };
483
+ }
484
+
485
+ function diagnosticDetails(diagnostic: CatalogDiagnostic) {
486
+ return {
487
+ severity: diagnostic.severity,
488
+ source: diagnostic.source,
489
+ message: diagnostic.message,
490
+ file_path: diagnostic.filePath,
491
+ };
492
+ }
493
+
494
+ function waveDetails(wave: WaveRecord) {
495
+ return {
496
+ wave_id: wave.id,
497
+ owner_session_id: wave.ownerSessionId,
498
+ worker_ids: [...wave.workerIds],
499
+ mode: wave.mode,
500
+ state: wave.state,
501
+ created_at: wave.createdAt,
502
+ };
503
+ }
504
+
505
+ function workerDetails(worker: WorkerRecord) {
506
+ return {
507
+ worker_id: worker.id,
508
+ worker: worker.worker,
509
+ owner_session_id: worker.ownerSessionId,
510
+ wave_id: worker.waveId,
511
+ title: worker.title,
512
+ lifecycle: worker.lifecycle,
513
+ status: worker.status,
514
+ activity: worker.activity,
515
+ usage: usageDetails(worker.usage),
516
+ outcome: worker.outcome ? outcomeDetails(worker.outcome) : undefined,
517
+ session_file: worker.sessionFile,
518
+ };
519
+ }
520
+
521
+ function usageDetails(usage: WorkerUsage) {
522
+ return {
523
+ input: usage.input,
524
+ output: usage.output,
525
+ cache_read: usage.cacheRead,
526
+ cache_write: usage.cacheWrite,
527
+ cost: usage.cost,
528
+ context_tokens: usage.contextTokens,
529
+ turns: usage.turns,
530
+ };
531
+ }
532
+
533
+ function outcomeDetails(outcome: WorkerOutcome) {
534
+ switch (outcome.status) {
535
+ case "completed":
536
+ case "ready":
537
+ return {
538
+ status: outcome.status,
539
+ assistant_text: outcome.assistantText,
540
+ };
541
+ case "failed":
542
+ case "aborted":
543
+ return {
544
+ status: outcome.status,
545
+ message: outcome.message,
546
+ assistant_text: outcome.assistantText,
547
+ };
548
+ case "closed":
549
+ return { status: outcome.status };
550
+ }
551
+ }
552
+
553
+ function readableDetails(title: string, details: unknown): string {
554
+ const content = `${title}\n\n${JSON.stringify(details, null, 2)}`;
555
+ const truncation = truncateHead(content);
556
+ if (!truncation.truncated) return content;
557
+
558
+ return `${truncation.content}\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full structured details remain available.]`;
559
+ }