@underactive/pi-topping-moa-fusion 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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/agents/mf-plan.md +43 -0
- package/agents/moa-debater.md +37 -0
- package/agents/moa-explore.md +56 -0
- package/agents/moa-opinion.md +29 -0
- package/agents/moa-proposer.md +49 -0
- package/agents/moa-synthesizer.md +124 -0
- package/agents/moa-verifier.md +67 -0
- package/index.ts +3 -0
- package/package.json +61 -0
- package/src/activityMeter.ts +193 -0
- package/src/agents/authoritative.ts +91 -0
- package/src/agents/defaults.ts +123 -0
- package/src/agents/discovery.ts +119 -0
- package/src/config/modelCatalogue.ts +54 -0
- package/src/config/planName.ts +74 -0
- package/src/config/rosters.ts +118 -0
- package/src/config/settings.ts +161 -0
- package/src/debate/debateContract.ts +89 -0
- package/src/debate/debateFanout.ts +285 -0
- package/src/debate/debateFile.ts +38 -0
- package/src/debate/debateResults.ts +115 -0
- package/src/debate/debateRounds.ts +61 -0
- package/src/debate/runDebate.ts +143 -0
- package/src/index.ts +283 -0
- package/src/moa/conflictContract.ts +49 -0
- package/src/moa/conflicts.ts +153 -0
- package/src/moa/contextContract.ts +52 -0
- package/src/moa/fanout.ts +152 -0
- package/src/moa/fanoutWiring.ts +88 -0
- package/src/moa/implementationRetry.ts +292 -0
- package/src/moa/modelRuntime.ts +87 -0
- package/src/moa/orchestration.ts +105 -0
- package/src/moa/planInfo.ts +57 -0
- package/src/moa/planlessRetry.ts +72 -0
- package/src/moa/reviewLoop.ts +170 -0
- package/src/moa/runContext.ts +118 -0
- package/src/moa/synthesis.ts +420 -0
- package/src/moa/verdicts.ts +81 -0
- package/src/moa/verification.ts +791 -0
- package/src/moa/verificationCriteria.ts +127 -0
- package/src/moa/verifyGate.ts +137 -0
- package/src/opinion/opinionContract.ts +21 -0
- package/src/opinion/opinionFanout.ts +135 -0
- package/src/opinion/opinionFile.ts +38 -0
- package/src/opinion/opinionResults.ts +73 -0
- package/src/opinion/runOpinion.ts +156 -0
- package/src/planning/askUserQuestion.ts +83 -0
- package/src/planning/instructions.ts +146 -0
- package/src/planning/modeState.ts +61 -0
- package/src/planning/planFile.ts +273 -0
- package/src/planning/planMode.ts +673 -0
- package/src/planning/tools/enterPlanMode.ts +165 -0
- package/src/planning/tools/exitPlanMode.ts +159 -0
- package/src/planning/tools/mfPlanSubagent.ts +311 -0
- package/src/planning/tools/shared.ts +19 -0
- package/src/planning/tools/writePlan.ts +33 -0
- package/src/runtime/activityTracking.ts +141 -0
- package/src/runtime/cancelRun.ts +134 -0
- package/src/runtime/mutationTripwire.ts +251 -0
- package/src/runtime/processPool.ts +55 -0
- package/src/runtime/results.ts +103 -0
- package/src/runtime/runner.ts +538 -0
- package/src/runtime/wire.ts +177 -0
- package/src/shared/functionKeys.ts +30 -0
- package/src/shared/modelRefs.ts +91 -0
- package/src/ui/agentStatus.ts +84 -0
- package/src/ui/agentTranscript.ts +112 -0
- package/src/ui/cancelOverlay.ts +191 -0
- package/src/ui/chrome.ts +151 -0
- package/src/ui/conflictOverlay.ts +363 -0
- package/src/ui/debateModelPicker.ts +273 -0
- package/src/ui/menu.ts +679 -0
- package/src/ui/moaModelPicker.ts +900 -0
- package/src/ui/moaProgressWidget.ts +910 -0
- package/src/ui/moaSetupOverlay.ts +368 -0
- package/src/ui/modelLabel.ts +61 -0
- package/src/ui/observeOverlay.ts +206 -0
- package/src/ui/opinionModelPicker.ts +246 -0
- package/src/ui/planReviewOverlay.ts +315 -0
- package/src/ui/promptEditor.ts +87 -0
- package/src/ui/rosterEditor.ts +310 -0
- package/src/ui/shimmer.ts +77 -0
- package/src/ui/toolActivity.ts +35 -0
- package/src/ui/twoPaneModelThinking.ts +272 -0
- package/src/ui/verificationFindingsOverlay.ts +137 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { OutputActivity } from "./activityTracking.ts";
|
|
3
|
+
|
|
4
|
+
export interface UsageStats {
|
|
5
|
+
input: number;
|
|
6
|
+
output: number;
|
|
7
|
+
cacheRead: number;
|
|
8
|
+
cacheWrite: number;
|
|
9
|
+
cacheWrite1h: number;
|
|
10
|
+
cost: number;
|
|
11
|
+
contextTokens?: number;
|
|
12
|
+
turns: number;
|
|
13
|
+
toolCalls: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SingleResult {
|
|
17
|
+
agent: string;
|
|
18
|
+
agentSource: "user" | "project" | "unknown";
|
|
19
|
+
task: string;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
messages: Message[];
|
|
22
|
+
stderr: string;
|
|
23
|
+
usage: UsageStats;
|
|
24
|
+
model?: string;
|
|
25
|
+
stopReason?: string;
|
|
26
|
+
errorMessage?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Live one-line description of the agent's most recent tool call (e.g.
|
|
29
|
+
* `grep "handleRequest"`). Transient — updated as the subprocess streams
|
|
30
|
+
* `tool_execution_start` events, for progress UIs. Not part of the final
|
|
31
|
+
* agent output.
|
|
32
|
+
*/
|
|
33
|
+
activity?: string;
|
|
34
|
+
/**
|
|
35
|
+
* In-progress assistant message from the most recent `message_update`.
|
|
36
|
+
* Transient — the completed message is moved into `messages` on `message_end`.
|
|
37
|
+
*/
|
|
38
|
+
partialAssistant?: Message;
|
|
39
|
+
/**
|
|
40
|
+
* Live generated-output reading driving the fan-out activity monitor.
|
|
41
|
+
* Distinct from `usage.output`, which only advances at turn boundaries and
|
|
42
|
+
* stays at 0 for providers that never report usage.
|
|
43
|
+
*/
|
|
44
|
+
outputActivity?: OutputActivity;
|
|
45
|
+
/**
|
|
46
|
+
* True when this agent was aborted (per-agent/user cancellation) and the
|
|
47
|
+
* caller opted into `resolveOnAbort`. Cancelled results still count as
|
|
48
|
+
* failed (`stopReason: "aborted"`), but callers can distinguish "user
|
|
49
|
+
* killed it" from "it broke".
|
|
50
|
+
*/
|
|
51
|
+
cancelled?: boolean;
|
|
52
|
+
/** Signal reported by the child close event, when it did not exit normally. */
|
|
53
|
+
signalCode?: NodeJS.Signals | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The text content of the last assistant message that actually said something:
|
|
59
|
+
* all its non-empty text parts joined with a blank line, falling back to
|
|
60
|
+
* earlier assistant messages when the last one only carried empty text or tool
|
|
61
|
+
* calls. Bridged providers can end a run with an empty trailing text block, so
|
|
62
|
+
* the last assistant message is not always the one holding the answer.
|
|
63
|
+
*/
|
|
64
|
+
export function getFinalOutput(messages: Message[]): string {
|
|
65
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
66
|
+
const msg = messages[i];
|
|
67
|
+
if (msg.role === "assistant") {
|
|
68
|
+
const text = msg.content
|
|
69
|
+
.filter((part) => part.type === "text")
|
|
70
|
+
.map((part) => (part as { text: string }).text)
|
|
71
|
+
.filter((text) => text.length > 0)
|
|
72
|
+
.join("\n\n");
|
|
73
|
+
if (text.length > 0) return text;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isFailedResult(result: { exitCode: number; stopReason?: string; messages: Message[] }): boolean {
|
|
80
|
+
return (
|
|
81
|
+
result.exitCode !== 0 ||
|
|
82
|
+
result.stopReason === "error" ||
|
|
83
|
+
result.stopReason === "aborted" ||
|
|
84
|
+
result.messages.length === 0
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getResultOutput(result: { exitCode: number; stopReason?: string; errorMessage?: string; stderr?: string; messages: Message[] }): string {
|
|
89
|
+
if (isFailedResult(result)) {
|
|
90
|
+
return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "No agent messages were emitted.";
|
|
91
|
+
}
|
|
92
|
+
return getFinalOutput(result.messages) || "(no output)";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function truncateOutput(output: string): string {
|
|
96
|
+
const maxBytes = 50 * 1024;
|
|
97
|
+
const bytes = Buffer.from(output, "utf8");
|
|
98
|
+
if (bytes.length <= maxBytes) return output;
|
|
99
|
+
|
|
100
|
+
let cut = maxBytes;
|
|
101
|
+
while (cut > 0 && (bytes[cut] & 0xc0) === 0x80) cut--;
|
|
102
|
+
return `${bytes.subarray(0, cut).toString("utf8")}\n\n[Output truncated]`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent runner — spawns isolated pi subprocesses for moa-explore/mf-plan agents.
|
|
3
|
+
* Ported from pi's subagent example.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
11
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { AgentConfig } from "../agents/discovery.ts";
|
|
13
|
+
import type { ThinkingLevel } from "../shared/modelRefs.ts";
|
|
14
|
+
import { OutputActivityTracker, PartialAssistantAssembler, formatToolActivity } from "./activityTracking.ts";
|
|
15
|
+
import { escalateKill, mapWithConcurrencyLimit, trackedProcesses } from "./processPool.ts";
|
|
16
|
+
import { getFinalOutput, type SingleResult } from "./results.ts";
|
|
17
|
+
import {
|
|
18
|
+
StderrBeaconReader,
|
|
19
|
+
isLlmMessage,
|
|
20
|
+
isNonnegativeFiniteNumber,
|
|
21
|
+
isValidContextTokens,
|
|
22
|
+
parseSessionEvent,
|
|
23
|
+
reconcileContextTokens,
|
|
24
|
+
} from "./wire.ts";
|
|
25
|
+
|
|
26
|
+
const MAX_PARALLEL_TASKS = 8;
|
|
27
|
+
const MAX_CONCURRENCY = 5;
|
|
28
|
+
const CHILD_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
29
|
+
const MAX_DIAGNOSTIC_CHARS = 65_536;
|
|
30
|
+
const READ_ONLY_AGENT_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
31
|
+
const READ_ONLY_AGENT_TOOL_SET = new Set<string>(READ_ONLY_AGENT_TOOLS);
|
|
32
|
+
/**
|
|
33
|
+
* Env handshake for provider bridges that wrap full local agents. Pi's --tools
|
|
34
|
+
* allowlist only gates PI'S tool loop — an agentic provider (e.g.
|
|
35
|
+
* cursor-bridge, whose models run Cursor agents with their own local
|
|
36
|
+
* edit/shell tools in the repo cwd) can mutate files no matter what pi allows.
|
|
37
|
+
* Every planning subprocess is spawned with these vars so such bridges switch
|
|
38
|
+
* to their native read-only mode (cursor-bridge maps this to Cursor's "plan"
|
|
39
|
+
* mode on the SDK path and `--mode plan` without `--force` on the CLI path;
|
|
40
|
+
* claude-bridge maps "read" to disallowing Claude Code's mutation tools on
|
|
41
|
+
* its provider path).
|
|
42
|
+
*/
|
|
43
|
+
export const READ_ONLY_SUBAGENT_ENV = Object.freeze({
|
|
44
|
+
PI_CURSOR_FORCE_MODE: "plan",
|
|
45
|
+
PI_CLAUDE_BRIDGE_FORCE_MODE: "read",
|
|
46
|
+
});
|
|
47
|
+
/** Opts provider extensions into the stderr usage-beacon protocol. */
|
|
48
|
+
const USAGE_BEACON_SUBAGENT_ENV = Object.freeze({ PI_USAGE_BEACON: "1" });
|
|
49
|
+
|
|
50
|
+
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
51
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-topping-moa-fusion-"));
|
|
52
|
+
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
53
|
+
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
54
|
+
await withFileMutationQueue(filePath, async () => {
|
|
55
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
56
|
+
});
|
|
57
|
+
return { dir: tmpDir, filePath };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getReadOnlyAgentTools(configuredTools?: string[]): string[] {
|
|
61
|
+
if (!configuredTools) return [...READ_ONLY_AGENT_TOOLS];
|
|
62
|
+
return configuredTools.filter((tool) => READ_ONLY_AGENT_TOOL_SET.has(tool));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
66
|
+
const currentScript = process.argv[1];
|
|
67
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
68
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
69
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
73
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
74
|
+
if (!isGenericRuntime) {
|
|
75
|
+
return { command: process.execPath, args };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { command: "pi", args };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RunSingleAgentOptions {
|
|
82
|
+
/**
|
|
83
|
+
* Load all configured pi extensions in the child process. Retained as a
|
|
84
|
+
* fallback for extension-registered providers whose entry point cannot be
|
|
85
|
+
* isolated explicitly.
|
|
86
|
+
*/
|
|
87
|
+
loadExtensions?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Load exactly this provider extension. Combined with `--no-extensions`,
|
|
90
|
+
* this keeps unrelated global/project extensions out of MoA child processes.
|
|
91
|
+
*/
|
|
92
|
+
extensionPath?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Called on every streamed turn boundary (each `message_end`) with the live,
|
|
95
|
+
* in-progress result. Lets callers observe running usage — notably
|
|
96
|
+
* `usage.contextTokens` (the latest turn's total context size) — so a progress
|
|
97
|
+
* widget can show a growing context-usage bar while the agent works. Providers
|
|
98
|
+
* that run a whole task as one turn only report at the end; see
|
|
99
|
+
* docs/handoff-bridge-live-context.md.
|
|
100
|
+
*/
|
|
101
|
+
onProgress?: (result: SingleResult) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Resolve with a `cancelled: true` result instead of throwing
|
|
104
|
+
* "Subagent was aborted" when the abort signal fires. Lets parallel
|
|
105
|
+
* orchestration treat a user-cancelled agent as a graceful failure while
|
|
106
|
+
* sibling agents keep running. The throwing default is kept for callers
|
|
107
|
+
* that string-match the abort error (e.g. the MoA ping's warm retry).
|
|
108
|
+
*/
|
|
109
|
+
resolveOnAbort?: boolean;
|
|
110
|
+
/** Abort grace period; exposed for focused process-boundary tests. */
|
|
111
|
+
killTimeoutMs?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function runSingleAgent(
|
|
115
|
+
defaultCwd: string,
|
|
116
|
+
agents: AgentConfig[],
|
|
117
|
+
agentName: string,
|
|
118
|
+
task: string,
|
|
119
|
+
cwd: string | undefined,
|
|
120
|
+
signal: AbortSignal | undefined,
|
|
121
|
+
onUpdate: ((partial: AgentToolResult<unknown>) => void) | undefined,
|
|
122
|
+
modelOverride?: string,
|
|
123
|
+
thinkingOverride?: ThinkingLevel,
|
|
124
|
+
options: RunSingleAgentOptions = {},
|
|
125
|
+
): Promise<SingleResult> {
|
|
126
|
+
const agent = agents.find((a) => a.name === agentName);
|
|
127
|
+
|
|
128
|
+
if (!agent) {
|
|
129
|
+
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
|
130
|
+
return {
|
|
131
|
+
agent: agentName,
|
|
132
|
+
agentSource: "unknown",
|
|
133
|
+
task,
|
|
134
|
+
exitCode: 1,
|
|
135
|
+
messages: [],
|
|
136
|
+
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
|
137
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
142
|
+
if (options.extensionPath) {
|
|
143
|
+
args.push("--no-extensions", "-e", options.extensionPath);
|
|
144
|
+
} else if (!options.loadExtensions) {
|
|
145
|
+
args.push("--no-extensions");
|
|
146
|
+
}
|
|
147
|
+
const effectiveModel = modelOverride ?? agent.model;
|
|
148
|
+
if (effectiveModel) args.push("--model", effectiveModel);
|
|
149
|
+
// MoA fan-out and mf-plan pass explicit levels; the frontmatter default
|
|
150
|
+
// only applies to tool-path agents spawned without one (e.g. moa-explore).
|
|
151
|
+
const effectiveThinking = thinkingOverride ?? agent.thinking;
|
|
152
|
+
if (effectiveThinking) args.push("--thinking", effectiveThinking);
|
|
153
|
+
// Always pass an explicit read-only allowlist. Agent definitions live in a
|
|
154
|
+
// user-writable directory and may be stale, customized, or omit `tools`;
|
|
155
|
+
// none of those cases may grant a planning subprocess mutation capabilities.
|
|
156
|
+
args.push("--tools", getReadOnlyAgentTools(agent.tools).join(","));
|
|
157
|
+
|
|
158
|
+
let tmpPromptDir: string | null = null;
|
|
159
|
+
let tmpPromptPath: string | null = null;
|
|
160
|
+
|
|
161
|
+
const currentResult: SingleResult = {
|
|
162
|
+
agent: agentName,
|
|
163
|
+
agentSource: agent.source,
|
|
164
|
+
task,
|
|
165
|
+
exitCode: 0,
|
|
166
|
+
messages: [],
|
|
167
|
+
stderr: "",
|
|
168
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 },
|
|
169
|
+
model: effectiveModel,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const taskBytes = Buffer.byteLength(task);
|
|
173
|
+
if (taskBytes > 128 * 1024) {
|
|
174
|
+
currentResult.stderr = `Task argument size: ${taskBytes} bytes (exceeds 128 KiB).\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const outputActivity = new OutputActivityTracker();
|
|
178
|
+
const partialMessage = new PartialAssistantAssembler();
|
|
179
|
+
|
|
180
|
+
const emitUpdate = () => {
|
|
181
|
+
currentResult.outputActivity = outputActivity.snapshot();
|
|
182
|
+
if (onUpdate) {
|
|
183
|
+
onUpdate({
|
|
184
|
+
details: undefined,
|
|
185
|
+
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
options.onProgress?.(currentResult);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
if (agent.systemPrompt.trim()) {
|
|
193
|
+
const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
194
|
+
tmpPromptDir = tmp.dir;
|
|
195
|
+
tmpPromptPath = tmp.filePath;
|
|
196
|
+
args.push("--append-system-prompt", tmpPromptPath);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
args.push(`Task: ${task}`);
|
|
200
|
+
let wasAborted = false;
|
|
201
|
+
|
|
202
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
203
|
+
const invocation = getPiInvocation(args);
|
|
204
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
205
|
+
cwd: cwd ?? defaultCwd,
|
|
206
|
+
shell: false,
|
|
207
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
208
|
+
// Read-only handshake for agentic provider bridges (see
|
|
209
|
+
// READ_ONLY_SUBAGENT_ENV). The pi --tools allowlist above cannot
|
|
210
|
+
// restrain a bridged agent's own tools.
|
|
211
|
+
env: { ...process.env, ...READ_ONLY_SUBAGENT_ENV, ...USAGE_BEACON_SUBAGENT_ENV },
|
|
212
|
+
});
|
|
213
|
+
trackedProcesses.add(proc);
|
|
214
|
+
|
|
215
|
+
// Auto-remove from tracking when the process exits (normal or otherwise).
|
|
216
|
+
proc.on("close", () => {
|
|
217
|
+
trackedProcesses.delete(proc);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
let buffer = "";
|
|
221
|
+
const stderrReader = new StderrBeaconReader();
|
|
222
|
+
let contextUsageIsAuthoritative = false;
|
|
223
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
224
|
+
const resetIdleTimer = () => {
|
|
225
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
226
|
+
idleTimer = setTimeout(() => escalateKill(proc, options.killTimeoutMs ?? 5000), CHILD_IDLE_TIMEOUT_MS);
|
|
227
|
+
};
|
|
228
|
+
resetIdleTimer();
|
|
229
|
+
|
|
230
|
+
const appendDiagnostics = (diagnostics: string) => {
|
|
231
|
+
currentResult.stderr = (currentResult.stderr + diagnostics).slice(-MAX_DIAGNOSTIC_CHARS);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const processLine = (line: string) => {
|
|
235
|
+
if (!line.trim()) return;
|
|
236
|
+
try {
|
|
237
|
+
const event = parseSessionEvent(line);
|
|
238
|
+
if (!event) {
|
|
239
|
+
appendDiagnostics("event parse error: invalid session event\n");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (event.type === "message_start") {
|
|
243
|
+
outputActivity.messageStart(event.message);
|
|
244
|
+
partialMessage.start(event.message);
|
|
245
|
+
if (event.message.role === "assistant") contextUsageIsAuthoritative = false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (event.type === "message_update") {
|
|
249
|
+
outputActivity.messageUpdate(event.assistantMessageEvent);
|
|
250
|
+
partialMessage.apply(event.assistantMessageEvent);
|
|
251
|
+
currentResult.partialAssistant = partialMessage.snapshot();
|
|
252
|
+
emitUpdate();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (event.type === "message_end" && isLlmMessage(event.message)) {
|
|
256
|
+
const msg = event.message;
|
|
257
|
+
currentResult.messages.push(msg);
|
|
258
|
+
currentResult.partialAssistant = undefined;
|
|
259
|
+
partialMessage.clear();
|
|
260
|
+
outputActivity.messageEnd(msg);
|
|
261
|
+
|
|
262
|
+
if (msg.role === "assistant") {
|
|
263
|
+
currentResult.usage.turns++;
|
|
264
|
+
const usage = msg.usage;
|
|
265
|
+
if (usage) {
|
|
266
|
+
const nonnegative = (value: unknown): number => isNonnegativeFiniteNumber(value) ? value : 0;
|
|
267
|
+
currentResult.usage.input += nonnegative(usage.input);
|
|
268
|
+
currentResult.usage.output += nonnegative(usage.output);
|
|
269
|
+
currentResult.usage.cacheRead += nonnegative(usage.cacheRead);
|
|
270
|
+
currentResult.usage.cacheWrite += nonnegative(usage.cacheWrite);
|
|
271
|
+
currentResult.usage.cacheWrite1h += nonnegative(usage.cacheWrite1h);
|
|
272
|
+
currentResult.usage.cost += nonnegative(usage.cost?.total);
|
|
273
|
+
if (isValidContextTokens(usage.totalTokens)) {
|
|
274
|
+
const reconciled = reconcileContextTokens(
|
|
275
|
+
currentResult.usage.contextTokens,
|
|
276
|
+
contextUsageIsAuthoritative,
|
|
277
|
+
usage.totalTokens,
|
|
278
|
+
"message_end",
|
|
279
|
+
);
|
|
280
|
+
currentResult.usage.contextTokens = reconciled.contextTokens;
|
|
281
|
+
contextUsageIsAuthoritative = reconciled.authoritative;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!currentResult.model && msg.model) currentResult.model = msg.model;
|
|
285
|
+
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
|
|
286
|
+
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
|
|
287
|
+
}
|
|
288
|
+
emitUpdate();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Real-time activity: surface the tool the agent is invoking right now,
|
|
292
|
+
// so progress UIs can show what each parallel agent is doing (and spot
|
|
293
|
+
// an agent stuck looping on the same call).
|
|
294
|
+
if (event.type === "tool_execution_start") {
|
|
295
|
+
currentResult.usage.toolCalls++;
|
|
296
|
+
currentResult.activity = formatToolActivity(event.toolName, event.args);
|
|
297
|
+
emitUpdate();
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
appendDiagnostics(`event parse error: ${String(error)}\n`);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const processStderr = (chunk: string, flush = false) => {
|
|
305
|
+
const { beacons, diagnostics } = stderrReader.push(chunk, flush);
|
|
306
|
+
appendDiagnostics(diagnostics);
|
|
307
|
+
for (const beacon of beacons) {
|
|
308
|
+
const reconciled = reconcileContextTokens(
|
|
309
|
+
currentResult.usage.contextTokens,
|
|
310
|
+
contextUsageIsAuthoritative,
|
|
311
|
+
beacon,
|
|
312
|
+
"beacon",
|
|
313
|
+
);
|
|
314
|
+
if (!reconciled.changed) continue;
|
|
315
|
+
currentResult.usage.contextTokens = reconciled.contextTokens;
|
|
316
|
+
contextUsageIsAuthoritative = reconciled.authoritative;
|
|
317
|
+
emitUpdate();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
proc.stdout.on("data", (data) => {
|
|
322
|
+
resetIdleTimer();
|
|
323
|
+
buffer += data.toString();
|
|
324
|
+
const lines = buffer.split("\n");
|
|
325
|
+
buffer = (lines.pop() || "").slice(-MAX_DIAGNOSTIC_CHARS);
|
|
326
|
+
for (const line of lines) processLine(line);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
proc.stderr.on("data", (data) => {
|
|
330
|
+
resetIdleTimer();
|
|
331
|
+
processStderr(data.toString());
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
proc.on("close", (code, closeSignal) => {
|
|
335
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
336
|
+
if (buffer.trim()) processLine(buffer);
|
|
337
|
+
processStderr("", true);
|
|
338
|
+
currentResult.signalCode = closeSignal;
|
|
339
|
+
resolve(code ?? (closeSignal ? 1 : 0));
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
proc.on("error", (error) => {
|
|
343
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
344
|
+
const errorCode = "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
345
|
+
const spawnError = `Failed to spawn subprocess${errorCode ? ` (${errorCode})` : ""}: ${error.message}`;
|
|
346
|
+
if (!currentResult.errorMessage) currentResult.errorMessage = spawnError;
|
|
347
|
+
if (!currentResult.stderr.includes(spawnError)) appendDiagnostics(`${spawnError}\n`);
|
|
348
|
+
resolve(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (signal) {
|
|
352
|
+
const killProc = () => {
|
|
353
|
+
wasAborted = true;
|
|
354
|
+
escalateKill(proc, options.killTimeoutMs ?? 5000);
|
|
355
|
+
};
|
|
356
|
+
if (signal.aborted) killProc();
|
|
357
|
+
else signal.addEventListener("abort", killProc, { once: true });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
currentResult.exitCode = exitCode;
|
|
362
|
+
if (wasAborted) {
|
|
363
|
+
if (options.resolveOnAbort) {
|
|
364
|
+
// A SIGTERM'd child reports close(null) → exitCode 0; force nonzero
|
|
365
|
+
// so isFailedResult/success counting stays honest.
|
|
366
|
+
if (currentResult.exitCode === 0) currentResult.exitCode = 130;
|
|
367
|
+
currentResult.stopReason = "aborted";
|
|
368
|
+
currentResult.cancelled = true;
|
|
369
|
+
if (!currentResult.errorMessage) currentResult.errorMessage = "Cancelled by user";
|
|
370
|
+
return currentResult;
|
|
371
|
+
}
|
|
372
|
+
throw new Error("Subagent was aborted");
|
|
373
|
+
}
|
|
374
|
+
return currentResult;
|
|
375
|
+
} finally {
|
|
376
|
+
if (tmpPromptPath)
|
|
377
|
+
try { fs.unlinkSync(tmpPromptPath); } catch { /* ignore */ }
|
|
378
|
+
if (tmpPromptDir)
|
|
379
|
+
try { fs.rmdirSync(tmpPromptDir); } catch { /* ignore */ }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Result for a task whose abort signal fired while it was still queued behind
|
|
385
|
+
* the concurrency limit — never spawned, reported as cancelled.
|
|
386
|
+
*/
|
|
387
|
+
function cancelledPlaceholderResult(agent: string, task: string): SingleResult {
|
|
388
|
+
return {
|
|
389
|
+
agent,
|
|
390
|
+
agentSource: "unknown",
|
|
391
|
+
task,
|
|
392
|
+
exitCode: 130,
|
|
393
|
+
messages: [],
|
|
394
|
+
stderr: "",
|
|
395
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 },
|
|
396
|
+
stopReason: "aborted",
|
|
397
|
+
errorMessage: "Cancelled by user",
|
|
398
|
+
cancelled: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export interface ParallelAgentTask {
|
|
403
|
+
agent: string;
|
|
404
|
+
task: string;
|
|
405
|
+
model?: string;
|
|
406
|
+
thinking?: ThinkingLevel;
|
|
407
|
+
loadExtensions?: boolean;
|
|
408
|
+
extensionPath?: string;
|
|
409
|
+
signal?: AbortSignal;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export interface ModelParallelAgentTask extends ParallelAgentTask {
|
|
413
|
+
model: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Run multiple agents in parallel with concurrency limit. */
|
|
417
|
+
export async function runParallelAgents(
|
|
418
|
+
defaultCwd: string,
|
|
419
|
+
agents: AgentConfig[],
|
|
420
|
+
tasks: ParallelAgentTask[],
|
|
421
|
+
signal: AbortSignal | undefined,
|
|
422
|
+
onUpdate: ((partial: AgentToolResult<unknown>) => void) | undefined,
|
|
423
|
+
onEach?: (index: number, result: SingleResult) => void,
|
|
424
|
+
onProgress?: (index: number, result: SingleResult) => void,
|
|
425
|
+
): Promise<SingleResult[]> {
|
|
426
|
+
if (tasks.length > MAX_PARALLEL_TASKS) {
|
|
427
|
+
throw new Error(`Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const allResults: SingleResult[] = new Array(tasks.length);
|
|
431
|
+
|
|
432
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
433
|
+
allResults[i] = {
|
|
434
|
+
agent: tasks[i].agent,
|
|
435
|
+
agentSource: "unknown",
|
|
436
|
+
task: tasks[i].task,
|
|
437
|
+
exitCode: -1, // -1 = still running
|
|
438
|
+
messages: [],
|
|
439
|
+
stderr: "",
|
|
440
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 },
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const emitParallelUpdate = () => {
|
|
445
|
+
if (onUpdate) {
|
|
446
|
+
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
447
|
+
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
|
448
|
+
onUpdate({
|
|
449
|
+
details: undefined,
|
|
450
|
+
content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
456
|
+
const taskSignal = t.signal ?? signal;
|
|
457
|
+
if (taskSignal?.aborted) {
|
|
458
|
+
// Cancelled while queued behind MAX_CONCURRENCY — never spawn.
|
|
459
|
+
const placeholder = cancelledPlaceholderResult(t.agent, t.task);
|
|
460
|
+
allResults[index] = placeholder;
|
|
461
|
+
emitParallelUpdate();
|
|
462
|
+
onEach?.(index, placeholder);
|
|
463
|
+
return placeholder;
|
|
464
|
+
}
|
|
465
|
+
const result = await runSingleAgent(
|
|
466
|
+
defaultCwd,
|
|
467
|
+
agents,
|
|
468
|
+
t.agent,
|
|
469
|
+
t.task,
|
|
470
|
+
undefined,
|
|
471
|
+
taskSignal,
|
|
472
|
+
undefined,
|
|
473
|
+
t.model,
|
|
474
|
+
t.thinking,
|
|
475
|
+
{
|
|
476
|
+
loadExtensions: t.loadExtensions,
|
|
477
|
+
extensionPath: t.extensionPath,
|
|
478
|
+
resolveOnAbort: true,
|
|
479
|
+
onProgress: onProgress ? (r) => onProgress(index, r) : undefined,
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
allResults[index] = result;
|
|
483
|
+
emitParallelUpdate();
|
|
484
|
+
onEach?.(index, result);
|
|
485
|
+
return result;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return results;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Run multiple agents in parallel, each with its own model override.
|
|
493
|
+
* Sibling to runParallelAgents for MoA fan-out, where every task uses the
|
|
494
|
+
* same agent (e.g. "moa-proposer") but a different model per slot.
|
|
495
|
+
*/
|
|
496
|
+
export async function runParallelAgentsWithModels(
|
|
497
|
+
defaultCwd: string,
|
|
498
|
+
agents: AgentConfig[],
|
|
499
|
+
tasks: ModelParallelAgentTask[],
|
|
500
|
+
signal: AbortSignal | undefined,
|
|
501
|
+
onEach: ((index: number, result: SingleResult) => void) | undefined,
|
|
502
|
+
onProgress?: (index: number, result: SingleResult) => void,
|
|
503
|
+
): Promise<SingleResult[]> {
|
|
504
|
+
if (tasks.length > MAX_PARALLEL_TASKS) {
|
|
505
|
+
throw new Error(`Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
509
|
+
const taskSignal = t.signal ?? signal;
|
|
510
|
+
if (taskSignal?.aborted) {
|
|
511
|
+
// Cancelled while queued behind MAX_CONCURRENCY — never spawn.
|
|
512
|
+
const placeholder = cancelledPlaceholderResult(t.agent, t.task);
|
|
513
|
+
onEach?.(index, placeholder);
|
|
514
|
+
return placeholder;
|
|
515
|
+
}
|
|
516
|
+
const result = await runSingleAgent(
|
|
517
|
+
defaultCwd,
|
|
518
|
+
agents,
|
|
519
|
+
t.agent,
|
|
520
|
+
t.task,
|
|
521
|
+
undefined,
|
|
522
|
+
taskSignal,
|
|
523
|
+
undefined,
|
|
524
|
+
t.model,
|
|
525
|
+
t.thinking,
|
|
526
|
+
{
|
|
527
|
+
loadExtensions: t.loadExtensions,
|
|
528
|
+
extensionPath: t.extensionPath,
|
|
529
|
+
resolveOnAbort: true,
|
|
530
|
+
onProgress: onProgress ? (r) => onProgress(index, r) : undefined,
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
onEach?.(index, result);
|
|
534
|
+
return result;
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
return results;
|
|
538
|
+
}
|