omnirush 0.9.0 → 0.9.1
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/assets/CHANGELOG.md +35 -0
- package/assets/extensions/omnirush/agents-lib.ts +370 -53
- package/assets/extensions/omnirush/agents.ts +92 -11
- package/assets/extensions/omnirush/bgshell-lib.ts +32 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
- package/assets/extensions/omnirush/collector.ts +203 -32
- package/assets/extensions/omnirush/index.ts +22 -8
- package/assets/extensions/omnirush/mcp.ts +103 -8
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +50 -2
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/subagent-marker.ts +21 -0
- package/package.json +1 -1
- package/scripts/smoke-packages.py +1 -0
- package/src/bin.js +28 -1
- package/src/sessions.js +222 -0
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// running pi entry — same runtime, same global extension/config dir)
|
|
7
7
|
// in the parent's workspace, with `--mode json -p` so their output is
|
|
8
8
|
// a parseable event stream. Each child writes its own session JSONL
|
|
9
|
-
// under <
|
|
9
|
+
// under <parent's session dir>/subagents/ (subagentSessionDir: kept out
|
|
10
|
+
// of the folder /resume and --continue read, so a user only ever sees
|
|
11
|
+
// their own conversations there), under a session id the parent
|
|
10
12
|
// picks (`--session-id`) and returns in the result (`session_id`): the
|
|
11
13
|
// parent session's collector reads the child's session file from it and
|
|
12
14
|
// records the child as a sub-agent of the turn (session.child events,
|
|
@@ -71,6 +73,47 @@ export function childInactivityMs(env: NodeJS.ProcessEnv = process.env): number
|
|
|
71
73
|
export const CHILD_KILL_GRACE_MS = 5_000;
|
|
72
74
|
/** Per-child output cap in the structured result (bytes, UTF-8). */
|
|
73
75
|
export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
|
|
76
|
+
/**
|
|
77
|
+
* Finished sub-agents whose result reached the model keep their output (up
|
|
78
|
+
* to CHILD_OUTPUT_CAP_BYTES each) for agents_result; beyond this many per
|
|
79
|
+
* session, the oldest ones' output is let go (a swarm session runs
|
|
80
|
+
* hundreds of children).
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_KEPT_OUTPUTS = 200;
|
|
83
|
+
/** What a released output reads. */
|
|
84
|
+
export const RELEASED_OUTPUT = "(output no longer kept in memory: it was delivered to the conversation earlier)";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sub-agent layers below the main session (the desktop app's
|
|
88
|
+
* OMNIRUSH_SUBAGENT_DEPTH): layers 1 and 2 may delegate, layer 3 may not.
|
|
89
|
+
* The session capture records exactly these layers (collector.ts).
|
|
90
|
+
*/
|
|
91
|
+
export const MAX_SUBAGENT_DEPTH = 3;
|
|
92
|
+
/** The layer a process runs at, handed down to every child (+1 per layer). */
|
|
93
|
+
export const ENV_AGENT_DEPTH = "OMNIRUSH_AGENT_DEPTH";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* This process's sub-agent layer: 0 for a main session. A child of a parent
|
|
97
|
+
* that predates the depth variable counts as layer 1.
|
|
98
|
+
*/
|
|
99
|
+
export function agentDepth(env: NodeJS.ProcessEnv = process.env): number {
|
|
100
|
+
const text = String(env[ENV_AGENT_DEPTH] ?? "").trim();
|
|
101
|
+
const raw = /^\d+$/.test(text) ? Number(text) : NaN;
|
|
102
|
+
if (Number.isSafeInteger(raw)) return raw;
|
|
103
|
+
return String(env.OMNIRUSH_PARENT_SESSION ?? "").trim() ? 1 : 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a process at `depth` may start sub-agents. */
|
|
107
|
+
export function canDelegate(depth: number): boolean {
|
|
108
|
+
return depth < MAX_SUBAGENT_DEPTH;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** System text for a sub-agent: its layer, and whether it may delegate. */
|
|
112
|
+
export function subagentLayerNote(depth: number): string {
|
|
113
|
+
return canDelegate(depth)
|
|
114
|
+
? `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. You may delegate independent parts of your task with spawn_agents (your sub-agents are layer ${depth + 1}); otherwise do the work yourself.`
|
|
115
|
+
: `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. You cannot delegate further (there is no spawn_agents at this layer): do the work yourself.`;
|
|
116
|
+
}
|
|
74
117
|
|
|
75
118
|
export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
|
|
76
119
|
export type AgentRole = (typeof AGENT_ROLES)[number];
|
|
@@ -161,6 +204,25 @@ export function childInvocation(args: string[]): {
|
|
|
161
204
|
return { command: "pi", args };
|
|
162
205
|
}
|
|
163
206
|
|
|
207
|
+
/** The folder, inside a session dir, that holds its sub-agents' sessions. */
|
|
208
|
+
export const SUBAGENT_SESSIONS_DIR = "subagents";
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Where a child's session file goes: <parent's session dir>/subagents/.
|
|
212
|
+
* The core's session picker (/resume) and --continue only read the
|
|
213
|
+
* *.jsonl files directly in a session dir, so children stored one level
|
|
214
|
+
* down never show up as sessions to resume. A sub-agent's own children
|
|
215
|
+
* (nested) share its folder: a parent that is itself a sub-agent
|
|
216
|
+
* (OMNIRUSH_PARENT_SESSION set) already runs there. Undefined when the
|
|
217
|
+
* parent has no session dir (--no-session): the child keeps the core's
|
|
218
|
+
* default, and the launcher tidies such stragglers (src/sessions.js).
|
|
219
|
+
*/
|
|
220
|
+
export function subagentSessionDir(parentSessionDir: unknown, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
221
|
+
if (typeof parentSessionDir !== "string" || !parentSessionDir.trim()) return undefined;
|
|
222
|
+
if (String(env.OMNIRUSH_PARENT_SESSION ?? "").trim()) return parentSessionDir;
|
|
223
|
+
return path.join(parentSessionDir, SUBAGENT_SESSIONS_DIR);
|
|
224
|
+
}
|
|
225
|
+
|
|
164
226
|
/**
|
|
165
227
|
* Build the child's argv for one task: JSON print mode with the role's
|
|
166
228
|
* system prompt appended via a temp file path (the caller writes and
|
|
@@ -175,6 +237,7 @@ export function buildChildArgs(
|
|
|
175
237
|
sessionId?: string,
|
|
176
238
|
effort?: string | null,
|
|
177
239
|
approve = false,
|
|
240
|
+
sessionDir?: string,
|
|
178
241
|
): string[] {
|
|
179
242
|
const args: string[] = ["--mode", "json", "-p"];
|
|
180
243
|
if (approve) {
|
|
@@ -187,6 +250,10 @@ export function buildChildArgs(
|
|
|
187
250
|
// be found and captured as this session's sub-agent.
|
|
188
251
|
args.push("--session-id", sessionId);
|
|
189
252
|
}
|
|
253
|
+
if (sessionDir) {
|
|
254
|
+
// Out of the user's resume list (subagentSessionDir).
|
|
255
|
+
args.push("--session-dir", sessionDir);
|
|
256
|
+
}
|
|
190
257
|
if (promptFilePath) {
|
|
191
258
|
args.push("--append-system-prompt", promptFilePath);
|
|
192
259
|
}
|
|
@@ -351,38 +418,155 @@ export interface ParsedChildEvents {
|
|
|
351
418
|
turns: number;
|
|
352
419
|
}
|
|
353
420
|
|
|
421
|
+
/** Characters of an event line read to tell its type (and role / tool call id) apart. */
|
|
422
|
+
export const CHILD_EVENT_HEAD_CHARS = 512;
|
|
354
423
|
/**
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
424
|
+
* Longest event line kept whole for parsing (characters). Only an
|
|
425
|
+
* assistant `message_end` is ever kept; one longer than this (a huge tool
|
|
426
|
+
* call argument) still counts as a turn, its text is not read.
|
|
358
427
|
*/
|
|
359
|
-
export
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
428
|
+
export const MAX_CHILD_EVENT_LINE_CHARS = 4 * 1024 * 1024;
|
|
429
|
+
|
|
430
|
+
const EVENT_TYPE_RE = /^\s*\{\s*"type"\s*:\s*"([A-Za-z_]+)"/;
|
|
431
|
+
const TOOL_CALL_ID_RE = /"toolCallId"\s*:\s*"((?:[^"\\]|\\.)*)"/;
|
|
432
|
+
const ROLE_RE = /^\s*\{\s*"type"\s*:\s*"message_end"\s*,\s*"message"\s*:\s*\{\s*"role"\s*:\s*"([A-Za-z_]+)"/;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Streaming reader of a `pi --mode json -p` child's stdout: one JSON event
|
|
436
|
+
* per line. Only what the parent needs is kept — the last assistant text,
|
|
437
|
+
* the assistant turn count and the tool calls in flight — and every other
|
|
438
|
+
* event (streaming deltas, tool results, turn_end / agent_end, which repeat
|
|
439
|
+
* the whole conversation) is skipped as it streams by, without buffering
|
|
440
|
+
* it. A child's stream is several times its conversation (agent_end alone
|
|
441
|
+
* repeats all of it), so keeping it whole cost the parent that much memory
|
|
442
|
+
* per child. Unparseable lines (banners, noise) are ignored.
|
|
443
|
+
*/
|
|
444
|
+
export class ChildEventScanner {
|
|
445
|
+
finalText = "";
|
|
446
|
+
turns = 0;
|
|
447
|
+
readonly toolsRunning = new Set<string>();
|
|
448
|
+
private line = "";
|
|
449
|
+
/** head: deciding from the first characters; keep: buffering the whole line; skip: dropping it. */
|
|
450
|
+
private mode: "head" | "keep" | "skip" = "head";
|
|
451
|
+
private lineType: string | null = null;
|
|
452
|
+
private lineRole: string | null = null;
|
|
453
|
+
|
|
454
|
+
/** Feed a decoded chunk of stdout. */
|
|
455
|
+
push(text: string): void {
|
|
456
|
+
let start = 0;
|
|
457
|
+
while (start <= text.length) {
|
|
458
|
+
const newline = text.indexOf("\n", start);
|
|
459
|
+
const end = newline < 0 ? text.length : newline;
|
|
460
|
+
if (end > start) this.feed(text.slice(start, end));
|
|
461
|
+
if (newline < 0) break;
|
|
462
|
+
this.endLine();
|
|
463
|
+
start = newline + 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The stream ended: a last line without a newline still counts. */
|
|
468
|
+
end(): void {
|
|
469
|
+
if (this.line || this.mode !== "head") this.endLine();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private feed(part: string): void {
|
|
473
|
+
if (this.mode === "skip") return;
|
|
474
|
+
this.line += part;
|
|
475
|
+
if (this.mode === "head" && this.line.length >= CHILD_EVENT_HEAD_CHARS) this.decide();
|
|
476
|
+
if (this.mode === "keep" && this.line.length > MAX_CHILD_EVENT_LINE_CHARS) {
|
|
477
|
+
// Too long to keep: an assistant message still counts as a turn.
|
|
478
|
+
if (this.lineType === "message_end" && this.lineRole === "assistant") this.turns += 1;
|
|
479
|
+
this.line = "";
|
|
480
|
+
this.mode = "skip";
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Classify the line from its head: keep it, take what it says from the head, or skip it. */
|
|
485
|
+
private decide(): void {
|
|
486
|
+
const head = this.line.slice(0, CHILD_EVENT_HEAD_CHARS);
|
|
487
|
+
const type = EVENT_TYPE_RE.exec(head)?.[1] ?? null;
|
|
488
|
+
this.lineType = type;
|
|
489
|
+
if (type === "message_end") {
|
|
490
|
+
const role = ROLE_RE.exec(head)?.[1] ?? null;
|
|
491
|
+
this.lineRole = role;
|
|
492
|
+
// Only an assistant message carries the final text; a role further
|
|
493
|
+
// into the object than the head (not how pi writes it) is kept too.
|
|
494
|
+
if (role !== null && role !== "assistant") this.skip();
|
|
495
|
+
else this.mode = "keep";
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (type === "tool_execution_start" || type === "tool_execution_end") {
|
|
499
|
+
const id = TOOL_CALL_ID_RE.exec(head)?.[1];
|
|
500
|
+
if (id !== undefined) {
|
|
501
|
+
this.tool(type, JSON.parse(`"${id}"`));
|
|
502
|
+
this.skip();
|
|
503
|
+
} else {
|
|
504
|
+
this.mode = "keep";
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
this.skip();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private skip(): void {
|
|
512
|
+
this.line = "";
|
|
513
|
+
this.mode = "skip";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private tool(type: string, id: string): void {
|
|
517
|
+
if (type === "tool_execution_start") this.toolsRunning.add(id);
|
|
518
|
+
else this.toolsRunning.delete(id);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private endLine(): void {
|
|
522
|
+
const line = this.line;
|
|
523
|
+
const mode = this.mode;
|
|
524
|
+
this.line = "";
|
|
525
|
+
this.mode = "head";
|
|
526
|
+
this.lineType = null;
|
|
527
|
+
this.lineRole = null;
|
|
528
|
+
if (mode === "skip") return;
|
|
363
529
|
const trimmed = line.trim();
|
|
364
|
-
if (!trimmed)
|
|
530
|
+
if (!trimmed || !trimmed.includes('"type"')) return;
|
|
531
|
+
// A short line: skip the types nothing is read from without parsing them.
|
|
532
|
+
const type = mode === "head" ? EVENT_TYPE_RE.exec(trimmed)?.[1] : undefined;
|
|
533
|
+
if (type && type !== "message_end" && type !== "tool_execution_start" && type !== "tool_execution_end") return;
|
|
365
534
|
let event: any;
|
|
366
535
|
try {
|
|
367
536
|
event = JSON.parse(trimmed);
|
|
368
537
|
} catch {
|
|
369
|
-
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if ((event?.type === "tool_execution_start" || event?.type === "tool_execution_end") && typeof event.toolCallId === "string") {
|
|
541
|
+
this.tool(event.type, event.toolCallId);
|
|
542
|
+
return;
|
|
370
543
|
}
|
|
371
|
-
if (event?.type !== "message_end" || !event.message)
|
|
544
|
+
if (event?.type !== "message_end" || !event.message) return;
|
|
372
545
|
const message = event.message;
|
|
373
|
-
if (message.role !== "assistant")
|
|
374
|
-
turns += 1;
|
|
546
|
+
if (message.role !== "assistant") return;
|
|
547
|
+
this.turns += 1;
|
|
375
548
|
const parts = Array.isArray(message.content) ? message.content : [];
|
|
376
549
|
const text = parts
|
|
377
550
|
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
378
551
|
.map((part: any) => part.text)
|
|
379
552
|
.join("\n\n");
|
|
380
|
-
if (text.trim()) finalText = text;
|
|
553
|
+
if (text.trim()) this.finalText = text;
|
|
381
554
|
else if (typeof message.content === "string" && message.content.trim()) {
|
|
382
|
-
finalText = message.content;
|
|
555
|
+
this.finalText = message.content;
|
|
383
556
|
}
|
|
384
557
|
}
|
|
385
|
-
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Parse the stdout of a `pi --mode json -p` run: one JSON event per line;
|
|
562
|
+
* `message_end` events carry the messages (see ChildEventScanner, which
|
|
563
|
+
* the runner feeds as the output streams).
|
|
564
|
+
*/
|
|
565
|
+
export function parseChildEvents(stdout: string): ParsedChildEvents {
|
|
566
|
+
const scanner = new ChildEventScanner();
|
|
567
|
+
scanner.push(stdout);
|
|
568
|
+
scanner.end();
|
|
569
|
+
return { finalText: scanner.finalText, turns: scanner.turns };
|
|
386
570
|
}
|
|
387
571
|
|
|
388
572
|
// --- the runner -------------------------------------------------------------
|
|
@@ -390,6 +574,8 @@ export function parseChildEvents(stdout: string): ParsedChildEvents {
|
|
|
390
574
|
export interface SpawnChildOptions {
|
|
391
575
|
cwd: string;
|
|
392
576
|
parentSessionId: string;
|
|
577
|
+
/** Where the child's session file goes (subagentSessionDir); the core's default when unset. */
|
|
578
|
+
sessionDir?: string;
|
|
393
579
|
/**
|
|
394
580
|
* Explicit wall-clock limit (the tool's `timeout_minutes`). Undefined =
|
|
395
581
|
* none: a child runs until it is done, stalls (inactivityMs) or is
|
|
@@ -420,6 +606,10 @@ export interface SpawnChildOptions {
|
|
|
420
606
|
onActivity?: (activity: ChildActivity) => void;
|
|
421
607
|
/** The child's session id (a fresh UUID by default). */
|
|
422
608
|
sessionId?: string;
|
|
609
|
+
/** The child process started (its pid), for memory accounting. */
|
|
610
|
+
onSpawn?: (pid: number | undefined) => void;
|
|
611
|
+
/** Extra environment for the child (e.g. the memory tree root). */
|
|
612
|
+
extraEnv?: Record<string, string>;
|
|
423
613
|
}
|
|
424
614
|
|
|
425
615
|
export interface ChildActivity {
|
|
@@ -466,19 +656,20 @@ export async function runChildAgent(
|
|
|
466
656
|
const sessionId = options.sessionId ?? randomUUID();
|
|
467
657
|
|
|
468
658
|
return withRolePromptFile(task.role, async (promptFile) => {
|
|
469
|
-
const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive());
|
|
659
|
+
const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive(), options.sessionDir);
|
|
470
660
|
const invocation = childInvocation(args);
|
|
471
661
|
|
|
472
662
|
return await new Promise<ChildResult>((resolvePromise) => {
|
|
473
|
-
|
|
663
|
+
// The event stream is read as it arrives; nothing of it is kept but
|
|
664
|
+
// what the result needs (the scanner), so a long child costs the
|
|
665
|
+
// parent next to nothing.
|
|
666
|
+
const events = new ChildEventScanner();
|
|
474
667
|
let stderr = "";
|
|
475
|
-
let pendingLine = "";
|
|
476
668
|
let pendingErrLine = "";
|
|
477
669
|
let gatewayFallback: ChildResultInput["gatewayFallback"];
|
|
478
670
|
let settled = false;
|
|
479
671
|
let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
|
|
480
|
-
|
|
481
|
-
const toolsRunning = new Set<string>();
|
|
672
|
+
const toolsRunning = events.toolsRunning;
|
|
482
673
|
let lastActivity = now();
|
|
483
674
|
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
484
675
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -490,8 +681,9 @@ export async function runChildAgent(
|
|
|
490
681
|
// Shared credentials by location (OMNIRUSH_DIR), never a token
|
|
491
682
|
// frozen at the parent's launch: the child re-reads auth.json for
|
|
492
683
|
// every request and takes part in the refresh lock.
|
|
493
|
-
env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe() }),
|
|
684
|
+
env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe(), ...(options.extraEnv ?? {}) }),
|
|
494
685
|
});
|
|
686
|
+
options.onSpawn?.(child.pid);
|
|
495
687
|
|
|
496
688
|
const finish = (input: ChildResultInput) => {
|
|
497
689
|
if (settled) return;
|
|
@@ -537,7 +729,7 @@ export async function runChildAgent(
|
|
|
537
729
|
const alive = () => {
|
|
538
730
|
lastActivity = now();
|
|
539
731
|
armWatchdog();
|
|
540
|
-
options.onActivity?.({ at: lastActivity, turns, toolsRunning: toolsRunning.size });
|
|
732
|
+
options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size });
|
|
541
733
|
};
|
|
542
734
|
armWatchdog();
|
|
543
735
|
|
|
@@ -554,34 +746,20 @@ export async function runChildAgent(
|
|
|
554
746
|
else options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
555
747
|
}
|
|
556
748
|
|
|
557
|
-
|
|
558
|
-
const observe = (line: string) => {
|
|
559
|
-
if (!line.includes('"type"')) return;
|
|
560
|
-
let event: any;
|
|
561
|
-
try {
|
|
562
|
-
event = JSON.parse(line);
|
|
563
|
-
} catch {
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
if (event?.type === "tool_execution_start" && typeof event.toolCallId === "string") toolsRunning.add(event.toolCallId);
|
|
567
|
-
else if (event?.type === "tool_execution_end" && typeof event.toolCallId === "string") toolsRunning.delete(event.toolCallId);
|
|
568
|
-
else if (event?.type === "message_end" && event.message?.role === "assistant") turns += 1;
|
|
569
|
-
};
|
|
570
|
-
|
|
749
|
+
child.stdout?.setEncoding?.("utf8");
|
|
571
750
|
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
572
751
|
const text = String(chunk);
|
|
573
|
-
|
|
574
|
-
const lines = (pendingLine + text).split("\n");
|
|
575
|
-
pendingLine = lines.pop() ?? "";
|
|
576
|
-
for (const line of lines) observe(line);
|
|
752
|
+
events.push(text);
|
|
577
753
|
alive();
|
|
578
754
|
options.onChildStdout?.(task.role, text);
|
|
579
755
|
});
|
|
756
|
+
child.stderr?.setEncoding?.("utf8");
|
|
580
757
|
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
581
758
|
const text = String(chunk);
|
|
582
759
|
// The child's gateway guard reports a move to the main model here.
|
|
583
760
|
const lines = (pendingErrLine + text).split("\n");
|
|
584
761
|
pendingErrLine = lines.pop() ?? "";
|
|
762
|
+
if (pendingErrLine.length > 64 * 1024) pendingErrLine = pendingErrLine.slice(-64 * 1024);
|
|
585
763
|
for (const line of lines) gatewayFallback = parseFallbackMarker(line) ?? gatewayFallback;
|
|
586
764
|
stderr += text;
|
|
587
765
|
if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
|
|
@@ -597,7 +775,8 @@ export async function runChildAgent(
|
|
|
597
775
|
});
|
|
598
776
|
});
|
|
599
777
|
child.on("close", (code: number | null) => {
|
|
600
|
-
|
|
778
|
+
events.end();
|
|
779
|
+
const parsed = { finalText: events.finalText, turns: events.turns };
|
|
601
780
|
const partial = parsed.finalText ? " — partial result kept" : "";
|
|
602
781
|
if (killedFor) {
|
|
603
782
|
const error = killedFor === "timeout"
|
|
@@ -636,6 +815,8 @@ function formatMinutes(ms: number): string {
|
|
|
636
815
|
*/
|
|
637
816
|
export function childEnvironment(task: ChildTask, parentSessionId: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
638
817
|
const env: NodeJS.ProcessEnv = { ...base, ...(task.env ?? {}), OMNIRUSH_PARENT_SESSION: parentSessionId };
|
|
818
|
+
// One layer further down than the delegating agent.
|
|
819
|
+
env[ENV_AGENT_DEPTH] = String(agentDepth(base) + 1);
|
|
639
820
|
// Guarded mode: the name the parent's approval prompt shows for this child.
|
|
640
821
|
const oneLine = task.task.replace(/\s+/g, " ").trim();
|
|
641
822
|
env.OMNIRUSH_SUBAGENT_LABEL = `${ROLE_PRESETS[task.role]?.label ?? task.role}: ${oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine}`;
|
|
@@ -731,6 +912,8 @@ export interface AgentRecord {
|
|
|
731
912
|
/** Background delivery: one message per child, or one per batch. */
|
|
732
913
|
notify: "each" | "batch";
|
|
733
914
|
status: AgentState;
|
|
915
|
+
/** Queued because memory is short (see memory-lib.ts): why, and since when. */
|
|
916
|
+
waiting: { reason: "memory"; detail: string; since: number } | null;
|
|
734
917
|
queuedAt: number;
|
|
735
918
|
startedAt: number | null;
|
|
736
919
|
finishedAt: number | null;
|
|
@@ -749,14 +932,37 @@ export type AgentRunner = (
|
|
|
749
932
|
options: {
|
|
750
933
|
sessionId: string;
|
|
751
934
|
parentSessionId: string;
|
|
935
|
+
/** Where the child's session file goes (subagentSessionDir). */
|
|
936
|
+
sessionDir?: string;
|
|
752
937
|
signal: AbortSignal;
|
|
753
938
|
onActivity: (activity: ChildActivity) => void;
|
|
754
939
|
/** The call's explicit timeout_minutes, if any. */
|
|
755
940
|
timeoutMs?: number;
|
|
756
941
|
cwd?: string;
|
|
942
|
+
/** The child process started (its pid). */
|
|
943
|
+
onSpawn?: (pid: number | undefined) => void;
|
|
757
944
|
},
|
|
758
945
|
) => Promise<ChildResult>;
|
|
759
946
|
|
|
947
|
+
/**
|
|
948
|
+
* Memory-aware admission (memory-lib.ts MemoryAdmission): whether one more
|
|
949
|
+
* child may start, and the children it counts.
|
|
950
|
+
*/
|
|
951
|
+
export interface AgentAdmission {
|
|
952
|
+
check(): { ok: true } | { ok: false; reason: string; detail: string };
|
|
953
|
+
started(key: object, pid?: number | null): void;
|
|
954
|
+
spawned(key: object, pid: number | null | undefined): void;
|
|
955
|
+
finished(key: object): void;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** A queued child: waits for its batch's max_parallel slot and for memory. */
|
|
959
|
+
interface QueueEntry {
|
|
960
|
+
record: AgentRecord;
|
|
961
|
+
task: ChildTask;
|
|
962
|
+
options: DispatchOptions;
|
|
963
|
+
batch: { running: number; limit: number };
|
|
964
|
+
}
|
|
965
|
+
|
|
760
966
|
export interface DispatchOptions {
|
|
761
967
|
background: boolean;
|
|
762
968
|
/** Children running at once (default: all of them). */
|
|
@@ -772,6 +978,8 @@ export interface DispatchOptions {
|
|
|
772
978
|
timeoutMs?: number;
|
|
773
979
|
/** Workspace the children run in. */
|
|
774
980
|
cwd?: string;
|
|
981
|
+
/** Where the children's session files go (subagentSessionDir). */
|
|
982
|
+
sessionDir?: string;
|
|
775
983
|
}
|
|
776
984
|
|
|
777
985
|
/**
|
|
@@ -791,12 +999,33 @@ export class AgentManager {
|
|
|
791
999
|
private readonly resolvers = new Map<AgentRecord, (result: ChildResult) => void>();
|
|
792
1000
|
private nextAgent = 1;
|
|
793
1001
|
private nextBatch = 1;
|
|
794
|
-
|
|
795
|
-
|
|
1002
|
+
private readonly admission: AgentAdmission | null;
|
|
1003
|
+
private readonly retryMs: number;
|
|
1004
|
+
private readonly onMemoryWait: (info: { critical: boolean; detail: string; queued: number } | null) => void;
|
|
1005
|
+
private queue: QueueEntry[] = [];
|
|
1006
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1007
|
+
/** The last memory wait reported (null when nothing waits for memory). */
|
|
1008
|
+
private memoryWait: { critical: boolean; detail: string } | null = null;
|
|
1009
|
+
|
|
1010
|
+
constructor(options: {
|
|
1011
|
+
run: AgentRunner;
|
|
1012
|
+
now?: () => number;
|
|
1013
|
+
onDeliverable?: () => void;
|
|
1014
|
+
onChanged?: () => void;
|
|
1015
|
+
/** Memory-aware admission; none = children start as soon as their batch allows. */
|
|
1016
|
+
admission?: AgentAdmission | null;
|
|
1017
|
+
/** How often a child waiting for memory checks again (ms). */
|
|
1018
|
+
retryMs?: number;
|
|
1019
|
+
/** Children started or stopped waiting for memory (null: nothing waits any more). */
|
|
1020
|
+
onMemoryWait?: (info: { critical: boolean; detail: string; queued: number } | null) => void;
|
|
1021
|
+
}) {
|
|
796
1022
|
this.runner = options.run;
|
|
797
1023
|
this.now = options.now ?? Date.now;
|
|
798
1024
|
this.onDeliverable = options.onDeliverable ?? (() => undefined);
|
|
799
1025
|
this.onChanged = options.onChanged ?? (() => undefined);
|
|
1026
|
+
this.admission = options.admission ?? null;
|
|
1027
|
+
this.retryMs = options.retryMs ?? 1_000;
|
|
1028
|
+
this.onMemoryWait = options.onMemoryWait ?? (() => undefined);
|
|
800
1029
|
}
|
|
801
1030
|
|
|
802
1031
|
dispatch(
|
|
@@ -805,6 +1034,7 @@ export class AgentManager {
|
|
|
805
1034
|
options: DispatchOptions,
|
|
806
1035
|
): { batch: string; records: AgentRecord[]; done: Promise<ChildResult[]> } {
|
|
807
1036
|
const batch = `b${this.nextBatch++}`;
|
|
1037
|
+
this.releaseOldOutputs(parentSessionId);
|
|
808
1038
|
const list = this.bySession.get(parentSessionId) ?? [];
|
|
809
1039
|
this.bySession.set(parentSessionId, list);
|
|
810
1040
|
const at = this.now();
|
|
@@ -824,6 +1054,7 @@ export class AgentManager {
|
|
|
824
1054
|
parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
|
|
825
1055
|
notify: options.notify ?? "batch",
|
|
826
1056
|
status: "queued",
|
|
1057
|
+
waiting: null,
|
|
827
1058
|
queuedAt: at,
|
|
828
1059
|
startedAt: null,
|
|
829
1060
|
finishedAt: null,
|
|
@@ -847,19 +1078,81 @@ export class AgentManager {
|
|
|
847
1078
|
if (options.signal.aborted) abort();
|
|
848
1079
|
else options.signal.addEventListener("abort", abort, { once: true });
|
|
849
1080
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
});
|
|
1081
|
+
// No count cap: every task of the batch may run at once unless the call
|
|
1082
|
+
// set max_parallel; memory admission decides when each one starts.
|
|
1083
|
+
const slots = { running: 0, limit: Math.max(1, Math.floor(options.concurrency ?? records.length)) };
|
|
1084
|
+
records.forEach((record, index) => this.queue.push({ record, task: tasks[index], options, batch: slots }));
|
|
1085
|
+
this.pump();
|
|
856
1086
|
return { batch, records, done: Promise.all(records.map((record) => record.done)) };
|
|
857
1087
|
}
|
|
858
1088
|
|
|
859
|
-
|
|
1089
|
+
/**
|
|
1090
|
+
* Start queued children, oldest first, while their batch has a free slot
|
|
1091
|
+
* and memory admission allows; the rest wait for a child to finish or
|
|
1092
|
+
* for the next check (every retryMs while something waits for memory).
|
|
1093
|
+
*/
|
|
1094
|
+
private pump(): void {
|
|
1095
|
+
if (this.retryTimer) {
|
|
1096
|
+
clearTimeout(this.retryTimer);
|
|
1097
|
+
this.retryTimer = null;
|
|
1098
|
+
}
|
|
1099
|
+
let blocked: { reason: string; detail: string } | null = null;
|
|
1100
|
+
const still: QueueEntry[] = [];
|
|
1101
|
+
let changed = false;
|
|
1102
|
+
for (const entry of this.queue) {
|
|
1103
|
+
const { record } = entry;
|
|
1104
|
+
if (record.result) {
|
|
1105
|
+
// Cancelled while it waited: it never starts.
|
|
1106
|
+
entry.options.onSettled?.(record);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (blocked || entry.batch.running >= entry.batch.limit) {
|
|
1110
|
+
if (blocked && entry.batch.running < entry.batch.limit) changed = this.markWaiting(record, blocked.detail) || changed;
|
|
1111
|
+
still.push(entry);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const verdict = this.admission ? this.admission.check() : { ok: true as const };
|
|
1115
|
+
if (!verdict.ok) {
|
|
1116
|
+
blocked = verdict;
|
|
1117
|
+
changed = this.markWaiting(record, verdict.detail) || changed;
|
|
1118
|
+
still.push(entry);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
entry.batch.running += 1;
|
|
1122
|
+
void this.start(entry);
|
|
1123
|
+
}
|
|
1124
|
+
this.queue = still;
|
|
1125
|
+
const waiting = still.filter((entry) => entry.record.waiting);
|
|
1126
|
+
if (blocked && waiting.length > 0) {
|
|
1127
|
+
const info = { critical: blocked.reason === "critical", detail: blocked.detail };
|
|
1128
|
+
// Once when children start waiting, and when it turns critical (or back):
|
|
1129
|
+
// the figures in the detail change with every check.
|
|
1130
|
+
if (!this.memoryWait || this.memoryWait.critical !== info.critical) {
|
|
1131
|
+
this.memoryWait = info;
|
|
1132
|
+
this.onMemoryWait({ ...info, queued: waiting.length });
|
|
1133
|
+
}
|
|
1134
|
+
// A ref'd timer: a one-shot run must not end while children wait.
|
|
1135
|
+
this.retryTimer = setTimeout(() => this.pump(), this.retryMs);
|
|
1136
|
+
} else if (this.memoryWait) {
|
|
1137
|
+
this.memoryWait = null;
|
|
1138
|
+
this.onMemoryWait(null);
|
|
1139
|
+
}
|
|
1140
|
+
if (changed) this.onChanged();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private markWaiting(record: AgentRecord, detail: string): boolean {
|
|
1144
|
+
if (record.waiting?.detail === detail) return false;
|
|
1145
|
+
record.waiting = { reason: "memory", detail, since: record.waiting?.since ?? this.now() };
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
private async start(entry: QueueEntry): Promise<void> {
|
|
1150
|
+
const { record, task, options } = entry;
|
|
860
1151
|
record.status = "running";
|
|
1152
|
+
record.waiting = null;
|
|
861
1153
|
record.startedAt = this.now();
|
|
862
1154
|
record.lastActivityAt = record.startedAt;
|
|
1155
|
+
this.admission?.started(record);
|
|
863
1156
|
this.onChanged();
|
|
864
1157
|
options.onChanged?.();
|
|
865
1158
|
let result: ChildResult;
|
|
@@ -868,8 +1161,10 @@ export class AgentManager {
|
|
|
868
1161
|
sessionId: record.sessionId,
|
|
869
1162
|
parentSessionId: record.parentSessionId,
|
|
870
1163
|
signal: record.controller.signal,
|
|
1164
|
+
onSpawn: (pid) => this.admission?.spawned(record, pid),
|
|
871
1165
|
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
872
1166
|
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
1167
|
+
...(options.sessionDir ? { sessionDir: options.sessionDir } : {}),
|
|
873
1168
|
onActivity: (activity) => {
|
|
874
1169
|
record.lastActivityAt = activity.at;
|
|
875
1170
|
record.turns = activity.turns;
|
|
@@ -887,7 +1182,12 @@ export class AgentManager {
|
|
|
887
1182
|
error: error instanceof Error ? error.message : String(error),
|
|
888
1183
|
}, this.now() - (record.startedAt ?? this.now()), record.sessionId);
|
|
889
1184
|
}
|
|
1185
|
+
this.admission?.finished(record);
|
|
1186
|
+
entry.batch.running -= 1;
|
|
890
1187
|
this.settle(record, result);
|
|
1188
|
+
options.onSettled?.(record);
|
|
1189
|
+
// Its slot and its memory are free: the queue moves on.
|
|
1190
|
+
this.pump();
|
|
891
1191
|
}
|
|
892
1192
|
|
|
893
1193
|
private settle(record: AgentRecord, result: ChildResult): void {
|
|
@@ -919,6 +1219,18 @@ export class AgentManager {
|
|
|
919
1219
|
if (record.background && !record.delivered) this.onDeliverable();
|
|
920
1220
|
}
|
|
921
1221
|
|
|
1222
|
+
/** Memory: delivered results beyond the newest MAX_KEPT_OUTPUTS keep no output. */
|
|
1223
|
+
private releaseOldOutputs(parentSessionId: string): void {
|
|
1224
|
+
const list = this.bySession.get(parentSessionId) ?? [];
|
|
1225
|
+
let kept = 0;
|
|
1226
|
+
for (let index = list.length - 1; index >= 0; index--) {
|
|
1227
|
+
const result = list[index].result;
|
|
1228
|
+
if (!result || !list[index].delivered || result.output === RELEASED_OUTPUT) continue;
|
|
1229
|
+
kept += 1;
|
|
1230
|
+
if (kept > MAX_KEPT_OUTPUTS) list[index].result = { ...result, output: RELEASED_OUTPUT };
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
922
1234
|
list(parentSessionId: string): AgentRecord[] {
|
|
923
1235
|
return [...(this.bySession.get(parentSessionId) ?? [])];
|
|
924
1236
|
}
|
|
@@ -1026,6 +1338,7 @@ export class AgentManager {
|
|
|
1026
1338
|
}, 0, record.sessionId));
|
|
1027
1339
|
}
|
|
1028
1340
|
}
|
|
1341
|
+
if (live.some((record) => record.startedAt === null)) this.pump();
|
|
1029
1342
|
await Promise.all(live.map((record) => record.done));
|
|
1030
1343
|
}
|
|
1031
1344
|
|
|
@@ -1048,6 +1361,7 @@ export function agentStatusSummary(records: AgentRecord[]): string {
|
|
|
1048
1361
|
if (records.length === 0) return "No sub-agents in this session.";
|
|
1049
1362
|
const running = records.filter((record) => record.status === "running").length;
|
|
1050
1363
|
const queued = records.filter((record) => record.status === "queued").length;
|
|
1364
|
+
const forMemory = records.filter((record) => record.status === "queued" && record.waiting?.reason === "memory").length;
|
|
1051
1365
|
const settled = records.filter((record) => Boolean(record.result)).length;
|
|
1052
1366
|
const limits = [...new Set(records.map((record) => record.parallelLimit))];
|
|
1053
1367
|
const parallel = limits.length === 1 && limits[0] === null
|
|
@@ -1055,7 +1369,8 @@ export function agentStatusSummary(records: AgentRecord[]): string {
|
|
|
1055
1369
|
: limits.length === 1
|
|
1056
1370
|
? String(limits[0])
|
|
1057
1371
|
: "per-batch";
|
|
1058
|
-
|
|
1372
|
+
const queuedText = forMemory > 0 ? `${queued} queued: waiting for memory` : `${queued} queued`;
|
|
1373
|
+
return `${records.length} sub-agents: ${running} running, ${queuedText}, ${settled} settled (parallel: ${parallel})`;
|
|
1059
1374
|
}
|
|
1060
1375
|
|
|
1061
1376
|
/** One status line per sub-agent (agents_status, /agents). */
|
|
@@ -1064,7 +1379,8 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
|
|
|
1064
1379
|
const lines = records.map((record) => {
|
|
1065
1380
|
const since = record.startedAt ?? record.queuedAt;
|
|
1066
1381
|
const elapsed = minutesOf((record.finishedAt ?? now) - since);
|
|
1067
|
-
const
|
|
1382
|
+
const state = record.status === "queued" && record.waiting ? "queued: waiting for memory" : record.status;
|
|
1383
|
+
const bits = [`${record.id}`, `[${state}]`, record.role];
|
|
1068
1384
|
if (record.model) bits.push(`[${record.model}${record.effort ? ` · ${record.effort}` : ""}]`);
|
|
1069
1385
|
bits.push(record.background ? "background" : "blocking");
|
|
1070
1386
|
const detail: string[] = [`${elapsed} min`];
|
|
@@ -1074,6 +1390,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
|
|
|
1074
1390
|
detail.push(`last activity ${minutesOf(now - record.lastActivityAt)} min ago`);
|
|
1075
1391
|
}
|
|
1076
1392
|
if (record.result && !record.delivered) detail.push("result not yet read");
|
|
1393
|
+
if (!record.result && record.waiting) detail.push(record.waiting.detail);
|
|
1077
1394
|
const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
|
|
1078
1395
|
return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
|
|
1079
1396
|
});
|