@zachwill/pi-orchestrate 0.9.2 → 0.10.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,470 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ formatSize,
8
+ getAgentDir,
9
+ truncateHead,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { Type } from "typebox";
12
+ import type {
13
+ CatalogDiagnostic,
14
+ WorkerCatalog,
15
+ WorkerDefinition,
16
+ } from "../catalog/definition.js";
17
+ import {
18
+ MAX_WORKER_INSTRUCTIONS_LENGTH,
19
+ MAX_WORKER_TITLE_LENGTH,
20
+ type RunRecord,
21
+ type WorkerOutcome,
22
+ type WorkerRecord,
23
+ type WorkerUsage,
24
+ } from "../orchestration/model.js";
25
+ import type {
26
+ AbortTarget,
27
+ OrchestrationContext,
28
+ } from "../orchestration/admission.js";
29
+ import type {
30
+ AcceptedRun,
31
+ CompletedRun,
32
+ OwnerSnapshot,
33
+ SettlementListener,
34
+ WorkerRunResult,
35
+ } from "../orchestration/service.js";
36
+ import type { DispatchDecision } from "../parent/dispatch-policy.js";
37
+ import type { OrchestrationClient } from "../parent/process-host.js";
38
+ import {
39
+ interactiveCloseToolRenderer,
40
+ interactiveSendToolRenderer,
41
+ orchestrateToolRenderer,
42
+ workerAbortToolRenderer,
43
+ workerStatusToolRenderer,
44
+ } from "./tool-renderer.js";
45
+ import {
46
+ encodeInlineWorkerToolDetails,
47
+ type InlineWorkerSettlementDetails,
48
+ type WorkerSettlement,
49
+ } from "../orchestration/settlement.js";
50
+
51
+ const STRICT_OBJECT = { additionalProperties: false } as const;
52
+ const shortTextSchema = Type.String({
53
+ pattern: "\\S",
54
+ maxLength: MAX_WORKER_TITLE_LENGTH,
55
+ });
56
+ const instructionsSchema = Type.String({
57
+ pattern: "\\S",
58
+ maxLength: MAX_WORKER_INSTRUCTIONS_LENGTH,
59
+ });
60
+ const workerIdSchema = Type.String({ pattern: "^worker-\\S+$" });
61
+
62
+ const taskSchema = Type.Object(
63
+ {
64
+ worker: shortTextSchema,
65
+ title: shortTextSchema,
66
+ instructions: instructionsSchema,
67
+ },
68
+ STRICT_OBJECT,
69
+ );
70
+
71
+ const statusSchema = Type.Object({}, STRICT_OBJECT);
72
+
73
+ const interactiveSendSchema = Type.Object(
74
+ {
75
+ worker_id: workerIdSchema,
76
+ instructions: instructionsSchema,
77
+ },
78
+ STRICT_OBJECT,
79
+ );
80
+
81
+ const workerAbortSchema = Type.Union([
82
+ Type.Object(
83
+ {
84
+ worker_ids: Type.Array(workerIdSchema, { minItems: 1 }),
85
+ },
86
+ STRICT_OBJECT,
87
+ ),
88
+ Type.Object(
89
+ {
90
+ all: Type.Literal(true),
91
+ },
92
+ STRICT_OBJECT,
93
+ ),
94
+ ]);
95
+
96
+ const interactiveCloseSchema = Type.Object(
97
+ {
98
+ worker_id: workerIdSchema,
99
+ },
100
+ STRICT_OBJECT,
101
+ );
102
+
103
+ export interface OrchestrationToolDependencies {
104
+ readonly orchestration: OrchestrationClient;
105
+ getCatalog(ctx: ExtensionContext): WorkerCatalog;
106
+ getDispatchDecision(toolCallId: string): DispatchDecision;
107
+ }
108
+
109
+ export function registerOrchestrationTools(
110
+ pi: ExtensionAPI,
111
+ deps: OrchestrationToolDependencies,
112
+ ): void {
113
+ pi.registerTool({
114
+ name: "orchestrate",
115
+ label: "Orchestrate",
116
+ description:
117
+ "Dispatch fully briefed worker scopes. Pi executes native sibling tools concurrently; Pi Orchestrate treats a successfully admitted sole orchestrate call or pure sibling group as async. Mixing orchestrate with another tool makes it inline and blocking.",
118
+ promptSnippet: "Dispatch fully briefed parallel worker scopes",
119
+ promptGuidelines: [
120
+ "Spin up as many workers as needed to cover every useful parallel scope and distinct validation perspective. Treat user-named workers or counts as a floor unless explicitly capped, and reuse the same worker role across multiple calls when useful.",
121
+ "For an intended async wave of N workers, the next assistant response must contain exactly N separate, fully briefed orchestrate calls; one call is valid only when N=1. To run it asynchronously, include no other tool calls; harmless response text does not affect runtime classification.",
122
+ "When a parallel tool dispatcher is available, use it once with exactly N orchestrate entries and no other tools; for example, put N functions.orchestrate entries in multi_tool_use.parallel. Otherwise emit N native sibling orchestrate calls in one assistant response.",
123
+ "Form all N calls before emitting or finalizing the response. Never emit one call and wait for its result before forming the rest of the wave: a successfully admitted sole async orchestrate call returns terminate=true and ends the turn.",
124
+ ],
125
+ executionMode: "parallel",
126
+ parameters: taskSchema,
127
+ ...orchestrateToolRenderer,
128
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
129
+ const decision = deps.getDispatchDecision(toolCallId);
130
+ const mode = decision.mode;
131
+ const orchestrationContext = buildOrchestrationContext(ctx, deps, decision.synthesisGroup);
132
+ if (mode === "async") {
133
+ const acceptedRun = await deps.orchestration.orchestrate(
134
+ orchestrationContext,
135
+ params,
136
+ "async",
137
+ signal,
138
+ );
139
+ const readable = acceptedRunSummary(acceptedRun);
140
+ return {
141
+ ...readableToolResult(`Accepted async run ${readable.run_id}.`, readable),
142
+ terminate: true,
143
+ };
144
+ }
145
+
146
+ const completedRun = await deps.orchestration.orchestrate(
147
+ orchestrationContext,
148
+ params,
149
+ "inline",
150
+ signal,
151
+ createInlineSettlementListener(onUpdate),
152
+ );
153
+ const readable = completedRunSummary(completedRun);
154
+ return readableToolResult(
155
+ `Completed inline run ${readable.run_id}.`,
156
+ readable,
157
+ );
158
+ },
159
+ });
160
+
161
+ pi.registerTool({
162
+ name: "worker_status",
163
+ label: "Worker Status",
164
+ description:
165
+ "Diagnostics and recovery only: inspect trusted catalog entries, catalog diagnostics, and this session's orchestration state. Never poll for completion.",
166
+ promptSnippet: "Inspect owned worker state for diagnostics or recovery",
167
+ promptGuidelines: [
168
+ "Use worker_status only for diagnostics or recovery; never poll it for completion.",
169
+ ],
170
+ parameters: statusSchema,
171
+ ...workerStatusToolRenderer,
172
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
173
+ const ownerSessionId = ctx.sessionManager.getSessionId();
174
+ const catalog = deps.getCatalog(ctx);
175
+ const snapshot = await deps.orchestration.snapshot(ownerSessionId);
176
+ const readable = statusSummary(catalog, snapshot);
177
+ return readableToolResult(
178
+ "Worker diagnostics and recovery snapshot.",
179
+ readable,
180
+ );
181
+ },
182
+ });
183
+
184
+ pi.registerTool({
185
+ name: "interactive_send",
186
+ label: "Interactive Send",
187
+ description:
188
+ "Send follow-up instructions only to an owned lifecycle interactive worker whose status is ready. Never use for one-shot or completed workers; one-shot sessions terminate automatically. A sole tool call runs asynchronously; sibling tool calls make it inline and blocking.",
189
+ promptSnippet: "Use only for an owned lifecycle interactive worker with status ready; never one-shot/completed because one-shot sessions terminate automatically",
190
+ promptGuidelines: [
191
+ "Use interactive_send only for an owned lifecycle interactive worker whose status is ready; never use it for one-shot or completed workers because one-shot sessions terminate automatically.",
192
+ ],
193
+ parameters: interactiveSendSchema,
194
+ ...interactiveSendToolRenderer,
195
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
196
+ const workerId = params.worker_id;
197
+ const mode = deps.getDispatchDecision(toolCallId).mode;
198
+ const orchestrationContext = buildOrchestrationContext(ctx, deps);
199
+ if (mode === "async") {
200
+ const acceptedRun = await deps.orchestration.sendInteractive(
201
+ orchestrationContext,
202
+ workerId,
203
+ params.instructions,
204
+ "async",
205
+ signal,
206
+ );
207
+ const readable = acceptedRunSummary(acceptedRun);
208
+ return {
209
+ ...readableToolResult(`Accepted async run ${readable.run_id}.`, readable),
210
+ terminate: true,
211
+ };
212
+ }
213
+
214
+ const completedRun = await deps.orchestration.sendInteractive(
215
+ orchestrationContext,
216
+ workerId,
217
+ params.instructions,
218
+ "inline",
219
+ signal,
220
+ createInlineSettlementListener(onUpdate),
221
+ );
222
+ const readable = completedRunSummary(completedRun);
223
+ return readableToolResult(
224
+ `Completed inline run ${readable.run_id}.`,
225
+ readable,
226
+ );
227
+ },
228
+ });
229
+
230
+ pi.registerTool({
231
+ name: "worker_abort",
232
+ label: "Worker Abort",
233
+ description:
234
+ "Abort owned active work by worker IDs or all active owned workers. Use interactive_close for owned lifecycle interactive workers whose status is ready.",
235
+ promptSnippet: "Abort active owned workers by worker IDs or all",
236
+ promptGuidelines: [
237
+ "Use worker_abort only for active work; use interactive_close only for an owned lifecycle interactive worker whose status is ready, never for one-shot or completed workers because one-shot sessions terminate automatically.",
238
+ ],
239
+ parameters: workerAbortSchema,
240
+ ...workerAbortToolRenderer,
241
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
242
+ const ownerSessionId = ctx.sessionManager.getSessionId();
243
+ const target = normalizeAbortTarget(params);
244
+ await deps.orchestration.abort(ownerSessionId, target);
245
+ const readable = {
246
+ target: "worker_ids" in params
247
+ ? { worker_ids: params.worker_ids }
248
+ : { all: params.all },
249
+ };
250
+ return readableToolResult("Abort request completed.", readable);
251
+ },
252
+ });
253
+
254
+ pi.registerTool({
255
+ name: "interactive_close",
256
+ label: "Interactive Close",
257
+ description: "Close only an owned lifecycle interactive worker whose status is ready. Never use for one-shot or completed workers; one-shot sessions terminate automatically.",
258
+ promptSnippet: "Use only for an owned lifecycle interactive worker with status ready; never one-shot/completed because one-shot sessions terminate automatically",
259
+ promptGuidelines: [
260
+ "Use interactive_close only for an owned lifecycle interactive worker whose status is ready; never use it for one-shot or completed workers because one-shot sessions terminate automatically.",
261
+ ],
262
+ parameters: interactiveCloseSchema,
263
+ ...interactiveCloseToolRenderer,
264
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
265
+ const ownerSessionId = ctx.sessionManager.getSessionId();
266
+ const workerId = params.worker_id;
267
+ await deps.orchestration.closeInteractive(ownerSessionId, workerId);
268
+ const readable = { worker_id: workerId };
269
+ return readableToolResult(`Closed worker ${workerId}.`, readable);
270
+ },
271
+ });
272
+ }
273
+
274
+ function buildOrchestrationContext(
275
+ ctx: ExtensionContext,
276
+ deps: OrchestrationToolDependencies,
277
+ synthesisGroup?: DispatchDecision["synthesisGroup"],
278
+ ): OrchestrationContext {
279
+ return {
280
+ ownerSessionId: ctx.sessionManager.getSessionId(),
281
+ cwd: ctx.cwd,
282
+ agentDir: getAgentDir(),
283
+ parentSessionFile: ctx.sessionManager.getSessionFile(),
284
+ projectTrusted: ctx.isProjectTrusted(),
285
+ catalog: deps.getCatalog(ctx),
286
+ parentModel: ctx.model,
287
+ modelRegistry: ctx.modelRegistry,
288
+ ...(synthesisGroup ? { synthesisGroup } : {}),
289
+ };
290
+ }
291
+
292
+ function createInlineSettlementListener(
293
+ onUpdate: ((result: AgentToolResult<unknown>) => void) | undefined,
294
+ ): SettlementListener {
295
+ return (settlement) => {
296
+ onUpdate?.({
297
+ content: [{ type: "text", text: "Worker response received." }],
298
+ details: encodeInlineWorkerToolDetails({
299
+ mode: "inline",
300
+ result: inlineResultValue(settlement),
301
+ }),
302
+ });
303
+ };
304
+ }
305
+
306
+ function normalizeAbortTarget(params: {
307
+ worker_ids?: string[];
308
+ all?: boolean;
309
+ }): AbortTarget {
310
+ return {
311
+ ...(params.worker_ids !== undefined ? { workerIds: params.worker_ids } : {}),
312
+ ...(params.all !== undefined ? { all: params.all } : {}),
313
+ };
314
+ }
315
+
316
+ function acceptedRunSummary(run: AcceptedRun) {
317
+ return {
318
+ mode: "async" as const,
319
+ run_id: run.id,
320
+ worker_id: run.workerId,
321
+ };
322
+ }
323
+
324
+ function completedRunSummary(run: CompletedRun) {
325
+ return encodeInlineWorkerToolDetails({
326
+ mode: "inline",
327
+ runId: run.id,
328
+ ownerSessionId: run.ownerSessionId,
329
+ result: inlineResultValue(run.result),
330
+ });
331
+ }
332
+
333
+ function inlineResultValue(
334
+ result: WorkerRunResult | WorkerSettlement,
335
+ ): InlineWorkerSettlementDetails {
336
+ return {
337
+ workerId: result.workerId,
338
+ worker: result.worker,
339
+ title: result.title,
340
+ status: result.status,
341
+ outcome: result.outcome,
342
+ usage: result.usage,
343
+ startedAt: result.startedAt,
344
+ settledAt: result.settledAt,
345
+ ...(result.sessionFile === undefined
346
+ ? {}
347
+ : { sessionFile: result.sessionFile }),
348
+ };
349
+ }
350
+
351
+ function statusSummary(catalog: WorkerCatalog, snapshot: OwnerSnapshot) {
352
+ return {
353
+ catalog: {
354
+ workers: catalog.workers.map(catalogWorkerSummary),
355
+ diagnostics: catalog.diagnostics.map(diagnosticSummary),
356
+ },
357
+ state: {
358
+ runs: snapshot.runs.map(runSummary),
359
+ workers: snapshot.workers.map(workerSummary),
360
+ },
361
+ };
362
+ }
363
+
364
+ function catalogWorkerSummary(worker: WorkerDefinition) {
365
+ return {
366
+ name: worker.name,
367
+ description: worker.description,
368
+ lifecycle: worker.lifecycle,
369
+ source: {
370
+ kind: worker.source.kind,
371
+ file_path: worker.source.filePath,
372
+ },
373
+ tools: [...worker.tools],
374
+ skills: worker.skills === undefined ? undefined : [...worker.skills],
375
+ model: worker.model
376
+ ? { provider: worker.model.provider, model_id: worker.model.modelId }
377
+ : undefined,
378
+ thinking: worker.thinking,
379
+ compaction: worker.compaction
380
+ ? {
381
+ enabled: worker.compaction.enabled,
382
+ reserve_tokens: worker.compaction.reserveTokens,
383
+ keep_recent_tokens: worker.compaction.keepRecentTokens,
384
+ }
385
+ : undefined,
386
+ };
387
+ }
388
+
389
+ function diagnosticSummary(diagnostic: CatalogDiagnostic) {
390
+ return {
391
+ severity: diagnostic.severity,
392
+ source: diagnostic.source,
393
+ message: diagnostic.message,
394
+ file_path: diagnostic.filePath,
395
+ };
396
+ }
397
+
398
+ function runSummary(run: RunRecord) {
399
+ return {
400
+ run_id: run.id,
401
+ owner_session_id: run.ownerSessionId,
402
+ worker_id: run.workerId,
403
+ mode: run.mode,
404
+ state: run.state,
405
+ created_at: run.createdAt,
406
+ };
407
+ }
408
+
409
+ function workerSummary(worker: WorkerRecord) {
410
+ return {
411
+ worker_id: worker.id,
412
+ worker: worker.worker,
413
+ owner_session_id: worker.ownerSessionId,
414
+ run_id: worker.runId,
415
+ title: worker.title,
416
+ lifecycle: worker.lifecycle,
417
+ status: worker.status,
418
+ activity: worker.activity,
419
+ usage: usageSummary(worker.usage),
420
+ outcome: worker.outcome ? outcomeSummary(worker.outcome) : undefined,
421
+ session_file: worker.sessionFile,
422
+ };
423
+ }
424
+
425
+ function usageSummary(usage: WorkerUsage) {
426
+ return {
427
+ input: usage.input,
428
+ output: usage.output,
429
+ cache_read: usage.cacheRead,
430
+ cache_write: usage.cacheWrite,
431
+ cost: usage.cost,
432
+ context_tokens: usage.contextTokens,
433
+ turns: usage.turns,
434
+ };
435
+ }
436
+
437
+ function outcomeSummary(outcome: WorkerOutcome) {
438
+ switch (outcome.status) {
439
+ case "completed":
440
+ case "ready":
441
+ return {
442
+ status: outcome.status,
443
+ assistant_text: outcome.assistantText,
444
+ };
445
+ case "failed":
446
+ case "aborted":
447
+ return {
448
+ status: outcome.status,
449
+ message: outcome.message,
450
+ assistant_text: outcome.assistantText,
451
+ };
452
+ case "closed":
453
+ return { status: outcome.status };
454
+ }
455
+ }
456
+
457
+ function readableToolResult<T>(title: string, details: T) {
458
+ return {
459
+ content: [{ type: "text" as const, text: readableDetails(title, details) }],
460
+ details,
461
+ };
462
+ }
463
+
464
+ function readableDetails(title: string, details: unknown): string {
465
+ const content = `${title}\n\n${JSON.stringify(details, null, 2)}`;
466
+ const truncation = truncateHead(content);
467
+ if (!truncation.truncated) return content;
468
+
469
+ return `${truncation.content}\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full structured details remain available.]`;
470
+ }