@pi-ohm/subagents 0.6.3 → 0.6.4-dev.22132458683.1.8646f56

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,462 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Result } from "better-result";
3
+ import type { OhmRuntimeConfig } from "@pi-ohm/config";
4
+ import type { OhmSubagentDefinition } from "../catalog";
5
+ import { SubagentRuntimeError, type SubagentResult } from "../errors";
6
+
7
+ export interface TaskBackendStartInput {
8
+ readonly taskId: string;
9
+ readonly subagent: OhmSubagentDefinition;
10
+ readonly description: string;
11
+ readonly prompt: string;
12
+ readonly config: OhmRuntimeConfig;
13
+ readonly cwd: string;
14
+ readonly signal: AbortSignal | undefined;
15
+ }
16
+
17
+ export interface TaskBackendStartOutput {
18
+ readonly summary: string;
19
+ readonly output: string;
20
+ }
21
+
22
+ export interface TaskBackendSendInput {
23
+ readonly taskId: string;
24
+ readonly subagent: OhmSubagentDefinition;
25
+ readonly description: string;
26
+ readonly initialPrompt: string;
27
+ readonly followUpPrompts: readonly string[];
28
+ readonly prompt: string;
29
+ readonly config: OhmRuntimeConfig;
30
+ readonly cwd: string;
31
+ readonly signal: AbortSignal | undefined;
32
+ }
33
+
34
+ export interface TaskBackendSendOutput {
35
+ readonly summary: string;
36
+ readonly output: string;
37
+ }
38
+
39
+ export interface TaskExecutionBackend {
40
+ readonly id: string;
41
+ resolveBackendId?(config: OhmRuntimeConfig): string;
42
+ executeStart(
43
+ input: TaskBackendStartInput,
44
+ ): Promise<SubagentResult<TaskBackendStartOutput, SubagentRuntimeError>>;
45
+ executeSend(
46
+ input: TaskBackendSendInput,
47
+ ): Promise<SubagentResult<TaskBackendSendOutput, SubagentRuntimeError>>;
48
+ }
49
+
50
+ function truncate(input: string, maxLength: number): string {
51
+ if (input.length <= maxLength) return input;
52
+ return input.slice(0, maxLength - 1) + "…";
53
+ }
54
+
55
+ function firstLine(input: string): string {
56
+ const [line = ""] = input.split(/\r?\n/u);
57
+ return line.trim();
58
+ }
59
+
60
+ function normalizeOutput(stdout: string, stderr: string): string {
61
+ const trimmedStdout = stdout.trim();
62
+ if (trimmedStdout.length > 0) return trimmedStdout;
63
+
64
+ const trimmedStderr = stderr.trim();
65
+ if (trimmedStderr.length > 0) return trimmedStderr;
66
+
67
+ return "(no output)";
68
+ }
69
+
70
+ function getTimeoutMsFromEnv(): number {
71
+ const raw = process.env.OHM_SUBAGENTS_BACKEND_TIMEOUT_MS;
72
+ if (!raw) return 180_000;
73
+
74
+ const parsed = Number.parseInt(raw, 10);
75
+ if (!Number.isFinite(parsed) || parsed <= 0) return 180_000;
76
+ return parsed;
77
+ }
78
+
79
+ export interface PiCliRunnerInput {
80
+ readonly cwd: string;
81
+ readonly prompt: string;
82
+ readonly signal: AbortSignal | undefined;
83
+ readonly timeoutMs: number;
84
+ }
85
+
86
+ export interface PiCliRunnerResult {
87
+ readonly exitCode: number;
88
+ readonly stdout: string;
89
+ readonly stderr: string;
90
+ readonly timedOut: boolean;
91
+ readonly aborted: boolean;
92
+ }
93
+
94
+ export type PiCliRunner = (input: PiCliRunnerInput) => Promise<PiCliRunnerResult>;
95
+
96
+ const PI_CLI_TOOLS = "read,bash,edit,write,grep,find,ls";
97
+
98
+ export const runPiCliPrompt: PiCliRunner = async (
99
+ input: PiCliRunnerInput,
100
+ ): Promise<PiCliRunnerResult> => {
101
+ if (input.signal?.aborted) {
102
+ return {
103
+ exitCode: 130,
104
+ stdout: "",
105
+ stderr: "",
106
+ timedOut: false,
107
+ aborted: true,
108
+ };
109
+ }
110
+
111
+ return new Promise<PiCliRunnerResult>((resolve) => {
112
+ const args = [
113
+ "--print",
114
+ "--no-session",
115
+ "--no-extensions",
116
+ "--tools",
117
+ PI_CLI_TOOLS,
118
+ input.prompt,
119
+ ];
120
+
121
+ const proc = spawn("pi", args, {
122
+ cwd: input.cwd,
123
+ shell: false,
124
+ stdio: ["ignore", "pipe", "pipe"],
125
+ });
126
+
127
+ let stdout = "";
128
+ let stderr = "";
129
+ let timedOut = false;
130
+ let aborted = false;
131
+ let settled = false;
132
+
133
+ const timeout = setTimeout(() => {
134
+ timedOut = true;
135
+ proc.kill("SIGTERM");
136
+ setTimeout(() => {
137
+ if (!proc.killed) {
138
+ proc.kill("SIGKILL");
139
+ }
140
+ }, 1000);
141
+ }, input.timeoutMs);
142
+
143
+ const finish = (result: PiCliRunnerResult): void => {
144
+ if (settled) return;
145
+ settled = true;
146
+ clearTimeout(timeout);
147
+ if (input.signal) {
148
+ input.signal.removeEventListener("abort", onAbort);
149
+ }
150
+ resolve(result);
151
+ };
152
+
153
+ const onAbort = (): void => {
154
+ aborted = true;
155
+ proc.kill("SIGTERM");
156
+ setTimeout(() => {
157
+ if (!proc.killed) {
158
+ proc.kill("SIGKILL");
159
+ }
160
+ }, 1000);
161
+ };
162
+
163
+ if (input.signal) {
164
+ input.signal.addEventListener("abort", onAbort, { once: true });
165
+ }
166
+
167
+ proc.stdout.on("data", (chunk: Buffer | string) => {
168
+ stdout += chunk.toString();
169
+ });
170
+
171
+ proc.stderr.on("data", (chunk: Buffer | string) => {
172
+ stderr += chunk.toString();
173
+ });
174
+
175
+ proc.on("error", (error) => {
176
+ const nextStderr = `${stderr}\n${error.message}`.trim();
177
+ finish({
178
+ exitCode: 1,
179
+ stdout,
180
+ stderr: nextStderr,
181
+ timedOut,
182
+ aborted,
183
+ });
184
+ });
185
+
186
+ proc.on("close", (code) => {
187
+ finish({
188
+ exitCode: typeof code === "number" ? code : 1,
189
+ stdout,
190
+ stderr,
191
+ timedOut,
192
+ aborted,
193
+ });
194
+ });
195
+ });
196
+ };
197
+
198
+ function toAbortedError(
199
+ taskId: string,
200
+ stage: "execute_start" | "execute_send",
201
+ ): SubagentRuntimeError {
202
+ return new SubagentRuntimeError({
203
+ code: "task_aborted",
204
+ stage,
205
+ message: `Task ${taskId} was aborted before execution`,
206
+ meta: { taskId },
207
+ });
208
+ }
209
+
210
+ function toUnsupportedBackendError(
211
+ taskId: string,
212
+ stage: "execute_start" | "execute_send",
213
+ ): SubagentRuntimeError {
214
+ return new SubagentRuntimeError({
215
+ code: "unsupported_subagent_backend",
216
+ stage,
217
+ message: "Configured subagent backend is not implemented in @pi-ohm/subagents",
218
+ meta: { taskId, backend: "custom-plugin" },
219
+ });
220
+ }
221
+
222
+ function toBackendTimeoutError(taskId: string, stage: "execute_start" | "execute_send") {
223
+ return new SubagentRuntimeError({
224
+ code: "task_backend_timeout",
225
+ stage,
226
+ message: `Task ${taskId} timed out while waiting for subagent backend response`,
227
+ meta: { taskId },
228
+ });
229
+ }
230
+
231
+ function toBackendExecutionError(input: {
232
+ readonly taskId: string;
233
+ readonly stage: "execute_start" | "execute_send";
234
+ readonly exitCode: number;
235
+ readonly stderr: string;
236
+ }): SubagentRuntimeError {
237
+ const errorText = input.stderr.trim();
238
+ const summary =
239
+ errorText.length > 0 ? truncate(errorText, 260) : "subagent backend exited non-zero";
240
+
241
+ return new SubagentRuntimeError({
242
+ code: "task_backend_execution_failed",
243
+ stage: input.stage,
244
+ message: `Task ${input.taskId} backend failed: ${summary}`,
245
+ meta: {
246
+ taskId: input.taskId,
247
+ exitCode: input.exitCode,
248
+ stderr: errorText,
249
+ },
250
+ });
251
+ }
252
+
253
+ function buildStartPrompt(input: TaskBackendStartInput): string {
254
+ return [
255
+ `You are the ${input.subagent.name} subagent in Pi OHM.`,
256
+ "",
257
+ `Subagent summary: ${input.subagent.summary}`,
258
+ "When to use:",
259
+ ...input.subagent.whenToUse.map((line) => `- ${line}`),
260
+ "",
261
+ "Profile scaffold guidance:",
262
+ input.subagent.scaffoldPrompt,
263
+ "",
264
+ `Task description: ${input.description}`,
265
+ "",
266
+ "User task:",
267
+ input.prompt,
268
+ "",
269
+ "Return concrete findings/results. Avoid repeating this prompt verbatim.",
270
+ ].join("\n");
271
+ }
272
+
273
+ function buildSendPrompt(input: TaskBackendSendInput): string {
274
+ const priorPrompts = [input.initialPrompt, ...input.followUpPrompts]
275
+ .map((prompt, index) => `${index + 1}. ${prompt}`)
276
+ .join("\n");
277
+
278
+ return [
279
+ `You are continuing the ${input.subagent.name} subagent task.`,
280
+ `Task description: ${input.description}`,
281
+ "",
282
+ "Task history:",
283
+ priorPrompts,
284
+ "",
285
+ "Latest follow-up request:",
286
+ input.prompt,
287
+ "",
288
+ "Return only the updated findings/result.",
289
+ ].join("\n");
290
+ }
291
+
292
+ export class ScaffoldTaskExecutionBackend implements TaskExecutionBackend {
293
+ readonly id = "scaffold";
294
+
295
+ async executeStart(
296
+ input: TaskBackendStartInput,
297
+ ): Promise<SubagentResult<TaskBackendStartOutput, SubagentRuntimeError>> {
298
+ if (input.signal?.aborted) {
299
+ return Result.err(
300
+ new SubagentRuntimeError({
301
+ code: "task_aborted",
302
+ stage: "execute_start",
303
+ message: `Task ${input.taskId} was aborted before execution`,
304
+ meta: { taskId: input.taskId },
305
+ }),
306
+ );
307
+ }
308
+
309
+ const summary = `${input.subagent.name}: ${truncate(input.description, 72)}`;
310
+ const output = [
311
+ `subagent: ${input.subagent.id}`,
312
+ `description: ${input.description}`,
313
+ `prompt: ${truncate(input.prompt, 220)}`,
314
+ `backend: ${this.id}`,
315
+ `cwd: ${input.cwd}`,
316
+ `mode: ${input.config.defaultMode}`,
317
+ ].join("\n");
318
+
319
+ return Result.ok({ summary, output });
320
+ }
321
+
322
+ async executeSend(
323
+ input: TaskBackendSendInput,
324
+ ): Promise<SubagentResult<TaskBackendSendOutput, SubagentRuntimeError>> {
325
+ if (input.signal?.aborted) {
326
+ return Result.err(
327
+ new SubagentRuntimeError({
328
+ code: "task_aborted",
329
+ stage: "execute_send",
330
+ message: `Task ${input.taskId} was aborted before follow-up`,
331
+ meta: { taskId: input.taskId },
332
+ }),
333
+ );
334
+ }
335
+
336
+ const summary = `${input.subagent.name} follow-up: ${truncate(input.prompt, 72)}`;
337
+ const output = [
338
+ `subagent: ${input.subagent.id}`,
339
+ `description: ${input.description}`,
340
+ `initial_prompt: ${truncate(input.initialPrompt, 220)}`,
341
+ `follow_up_prompt: ${truncate(input.prompt, 220)}`,
342
+ `follow_up_count: ${input.followUpPrompts.length}`,
343
+ `backend: ${this.id}`,
344
+ `cwd: ${input.cwd}`,
345
+ `mode: ${input.config.defaultMode}`,
346
+ ].join("\n");
347
+
348
+ return Result.ok({ summary, output });
349
+ }
350
+ }
351
+
352
+ export class PiCliTaskExecutionBackend implements TaskExecutionBackend {
353
+ readonly id = "interactive-shell";
354
+ private readonly scaffoldBackend = new ScaffoldTaskExecutionBackend();
355
+
356
+ constructor(
357
+ private readonly runner: PiCliRunner = runPiCliPrompt,
358
+ private readonly timeoutMs: number = getTimeoutMsFromEnv(),
359
+ ) {}
360
+
361
+ resolveBackendId(config: OhmRuntimeConfig): string {
362
+ if (config.subagentBackend === "none") return this.scaffoldBackend.id;
363
+ if (config.subagentBackend === "custom-plugin") return "custom-plugin";
364
+ return this.id;
365
+ }
366
+
367
+ async executeStart(
368
+ input: TaskBackendStartInput,
369
+ ): Promise<SubagentResult<TaskBackendStartOutput, SubagentRuntimeError>> {
370
+ if (input.signal?.aborted) {
371
+ return Result.err(toAbortedError(input.taskId, "execute_start"));
372
+ }
373
+
374
+ if (input.config.subagentBackend === "none") {
375
+ return this.scaffoldBackend.executeStart(input);
376
+ }
377
+
378
+ if (input.config.subagentBackend === "custom-plugin") {
379
+ return Result.err(toUnsupportedBackendError(input.taskId, "execute_start"));
380
+ }
381
+
382
+ const run = await this.runner({
383
+ cwd: input.cwd,
384
+ prompt: buildStartPrompt(input),
385
+ signal: input.signal,
386
+ timeoutMs: this.timeoutMs,
387
+ });
388
+
389
+ if (run.aborted || input.signal?.aborted) {
390
+ return Result.err(toAbortedError(input.taskId, "execute_start"));
391
+ }
392
+
393
+ if (run.timedOut) {
394
+ return Result.err(toBackendTimeoutError(input.taskId, "execute_start"));
395
+ }
396
+
397
+ if (run.exitCode !== 0) {
398
+ return Result.err(
399
+ toBackendExecutionError({
400
+ taskId: input.taskId,
401
+ stage: "execute_start",
402
+ exitCode: run.exitCode,
403
+ stderr: run.stderr,
404
+ }),
405
+ );
406
+ }
407
+
408
+ const output = normalizeOutput(run.stdout, run.stderr);
409
+ const summary = `${input.subagent.name}: ${truncate(firstLine(output), 72)}`;
410
+ return Result.ok({ summary, output });
411
+ }
412
+
413
+ async executeSend(
414
+ input: TaskBackendSendInput,
415
+ ): Promise<SubagentResult<TaskBackendSendOutput, SubagentRuntimeError>> {
416
+ if (input.signal?.aborted) {
417
+ return Result.err(toAbortedError(input.taskId, "execute_send"));
418
+ }
419
+
420
+ if (input.config.subagentBackend === "none") {
421
+ return this.scaffoldBackend.executeSend(input);
422
+ }
423
+
424
+ if (input.config.subagentBackend === "custom-plugin") {
425
+ return Result.err(toUnsupportedBackendError(input.taskId, "execute_send"));
426
+ }
427
+
428
+ const run = await this.runner({
429
+ cwd: input.cwd,
430
+ prompt: buildSendPrompt(input),
431
+ signal: input.signal,
432
+ timeoutMs: this.timeoutMs,
433
+ });
434
+
435
+ if (run.aborted || input.signal?.aborted) {
436
+ return Result.err(toAbortedError(input.taskId, "execute_send"));
437
+ }
438
+
439
+ if (run.timedOut) {
440
+ return Result.err(toBackendTimeoutError(input.taskId, "execute_send"));
441
+ }
442
+
443
+ if (run.exitCode !== 0) {
444
+ return Result.err(
445
+ toBackendExecutionError({
446
+ taskId: input.taskId,
447
+ stage: "execute_send",
448
+ exitCode: run.exitCode,
449
+ stderr: run.stderr,
450
+ }),
451
+ );
452
+ }
453
+
454
+ const output = normalizeOutput(run.stdout, run.stderr);
455
+ const summary = `${input.subagent.name} follow-up: ${truncate(firstLine(output), 72)}`;
456
+ return Result.ok({ summary, output });
457
+ }
458
+ }
459
+
460
+ export function createDefaultTaskExecutionBackend(): TaskExecutionBackend {
461
+ return new PiCliTaskExecutionBackend();
462
+ }