@underactive/pi-topping-moa-fusion 0.1.0 → 0.2.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 +34 -0
- package/README.md +31 -8
- package/agents/moa-synthesizer.md +9 -6
- package/package.json +5 -5
- package/src/config/planName.ts +29 -24
- package/src/config/settings.ts +38 -1
- package/src/debate/debateFanout.ts +14 -4
- package/src/debate/runDebate.ts +13 -4
- package/src/index.ts +1 -1
- package/src/moa/conflictContract.ts +2 -1
- package/src/moa/contextContract.ts +1 -1
- package/src/moa/fanout.ts +115 -4
- package/src/moa/fanoutWiring.ts +11 -1
- package/src/moa/implementationRetry.ts +2 -2
- package/src/moa/orchestration.ts +3 -0
- package/src/moa/panelDependence.ts +87 -0
- package/src/moa/reviewLoop.ts +2 -2
- package/src/moa/synthesis.ts +12 -8
- package/src/moa/verification.ts +114 -17
- package/src/moa/verificationCriteria.ts +1 -1
- package/src/opinion/opinionFanout.ts +5 -4
- package/src/opinion/runOpinion.ts +14 -13
- package/src/planning/modeState.ts +17 -0
- package/src/planning/planFile.ts +2 -1
- package/src/planning/planMode.ts +46 -5
- package/src/planning/tools/exitPlanMode.ts +7 -2
- package/src/planning/tools/mfPlanSubagent.ts +3 -0
- package/src/runtime/activityTracking.ts +28 -12
- package/src/runtime/processPool.ts +2 -0
- package/src/runtime/runner.ts +45 -9
- package/src/runtime/wire.ts +11 -0
- package/src/ui/cancelOverlay.ts +1 -1
- package/src/ui/moaModelPicker.ts +11 -1
- package/src/ui/moaProgressWidget.ts +59 -8
- package/src/ui/moaSetupOverlay.ts +42 -4
- package/src/ui/workingOverlay.ts +265 -0
|
@@ -27,6 +27,7 @@ export interface OpinionFanoutOptions {
|
|
|
27
27
|
session: CancelSession;
|
|
28
28
|
widget: MoaProgressWidget;
|
|
29
29
|
agents: AgentConfig[];
|
|
30
|
+
maxConcurrency: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export type OpinionFanoutResult =
|
|
@@ -34,9 +35,9 @@ export type OpinionFanoutResult =
|
|
|
34
35
|
| { status: "cancelled" };
|
|
35
36
|
|
|
36
37
|
export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<OpinionFanoutResult> {
|
|
37
|
-
const { host, ctx, question, models, thinking, session, widget, agents } = options;
|
|
38
|
+
const { host, ctx, question, models, thinking, session, widget, agents, maxConcurrency } = options;
|
|
38
39
|
const originalTask = buildOpinionTask(question);
|
|
39
|
-
widget.startFanout(models, thinking);
|
|
40
|
+
widget.startFanout(models, thinking, maxConcurrency);
|
|
40
41
|
const observe: ObserveSession = {
|
|
41
42
|
title: "MoA opinions",
|
|
42
43
|
phase: "fanout",
|
|
@@ -57,7 +58,7 @@ export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<O
|
|
|
57
58
|
tasks: ModelParallelAgentTask[],
|
|
58
59
|
indexMap: (index: number) => number,
|
|
59
60
|
currentRun: CancelRun,
|
|
60
|
-
): Promise<SingleResult[]> => runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models, indexMap });
|
|
61
|
+
): Promise<SingleResult[]> => runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models, indexMap, maxConcurrency });
|
|
61
62
|
|
|
62
63
|
const run = new CancelRun();
|
|
63
64
|
session.title = "Opinion agents";
|
|
@@ -107,7 +108,7 @@ export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<O
|
|
|
107
108
|
const ref = models[opinionIndex];
|
|
108
109
|
const label = modelRefLabel(ref);
|
|
109
110
|
const retryTask = buildOpinionRetryTask(originalTask, getFinalOutput(results[opinionIndex].messages));
|
|
110
|
-
widget.update(opinionIndex, "
|
|
111
|
+
widget.update(opinionIndex, "queued", "retrying: no opinion produced");
|
|
111
112
|
const observed = observe.agents[opinionIndex];
|
|
112
113
|
if (observed) {
|
|
113
114
|
observed.state = "working";
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
3
2
|
|
|
4
3
|
import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
|
|
5
4
|
import { discoverAgents } from "../agents/discovery.ts";
|
|
6
5
|
import { summarizePlanPromptName } from "../config/planName.ts";
|
|
6
|
+
import { slugifyPlanName } from "../planning/planFile.ts";
|
|
7
7
|
import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
|
|
8
8
|
import { resolveContextWindow } from "../moa/modelRuntime.ts";
|
|
9
|
-
import type
|
|
9
|
+
import { type CancelSession, subscribeCancelOverlayOnEsc } from "../runtime/cancelRun.ts";
|
|
10
10
|
import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
|
|
11
11
|
import { type SendUserMessageOptions, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
12
12
|
import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
13
13
|
import type { ObserveSession } from "../ui/observeOverlay.ts";
|
|
14
14
|
import { showOpinionModelPicker } from "../ui/opinionModelPicker.ts";
|
|
15
15
|
import { showPromptEditor } from "../ui/promptEditor.ts";
|
|
16
|
+
import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
|
|
16
17
|
import { runOpinionFanout } from "./opinionFanout.ts";
|
|
17
18
|
import { repoOpinionDisplayPath, saveRepoOpinionFile } from "./opinionFile.ts";
|
|
18
19
|
import { collectOpinionOutcomes, formatOpinionsMarkdown } from "./opinionResults.ts";
|
|
@@ -45,15 +46,7 @@ export async function runInteractiveOpinion(
|
|
|
45
46
|
installShippedAgents();
|
|
46
47
|
const session: CancelSession = { title: "Opinion agents", run: undefined, overlayOpen: false };
|
|
47
48
|
host.setActiveCancelSession(session);
|
|
48
|
-
const unsubscribeEsc = ctx
|
|
49
|
-
? ctx.ui.onTerminalInput((data) => {
|
|
50
|
-
if (isKeyRelease(data) || !matchesKey(data, Key.escape)) return undefined;
|
|
51
|
-
if (host.getActiveObserveSession()?.overlayOpen) return undefined;
|
|
52
|
-
if (session.overlayOpen) return undefined;
|
|
53
|
-
if (!session.run) return undefined;
|
|
54
|
-
return host.openCancelOverlayIfActive(ctx) ? { consume: true } : undefined;
|
|
55
|
-
})
|
|
56
|
-
: undefined;
|
|
49
|
+
const unsubscribeEsc = subscribeCancelOverlayOnEsc(ctx, host, session);
|
|
57
50
|
|
|
58
51
|
try {
|
|
59
52
|
let prefill = initialPrompt?.trim() ?? "";
|
|
@@ -83,7 +76,11 @@ export async function runInteractiveOpinion(
|
|
|
83
76
|
thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections },
|
|
84
77
|
});
|
|
85
78
|
|
|
86
|
-
const slug = await
|
|
79
|
+
const slug = await withWorkingOverlay(
|
|
80
|
+
ctx,
|
|
81
|
+
planNamingOverlay("opinion", () => slugifyPlanName(question)),
|
|
82
|
+
(signal) => summarizePlanPromptName(ctx, question, { signal }),
|
|
83
|
+
);
|
|
87
84
|
try {
|
|
88
85
|
saveRepoOpinionFile(question, ctx.cwd, slug, "opinion-prompt");
|
|
89
86
|
} catch {
|
|
@@ -114,7 +111,7 @@ export async function runInteractiveOpinion(
|
|
|
114
111
|
host.setRunningProgressWidget(widget);
|
|
115
112
|
let outcome;
|
|
116
113
|
try {
|
|
117
|
-
outcome = await runOpinionFanout({ host, ctx, question, models, thinking, session, widget, agents });
|
|
114
|
+
outcome = await runOpinionFanout({ host, ctx, question, models, thinking, session, widget, agents, maxConcurrency: settings.maxConcurrentAgents });
|
|
118
115
|
} finally {
|
|
119
116
|
host.setRunningProgressWidget(undefined);
|
|
120
117
|
widget.stopWidget();
|
|
@@ -139,6 +136,10 @@ export async function runInteractiveOpinion(
|
|
|
139
136
|
} catch {
|
|
140
137
|
saved = false;
|
|
141
138
|
}
|
|
139
|
+
// `sendUserMessage` routes to prompt() and ignores triggerTurn (only
|
|
140
|
+
// sendCustomMessage honors it), so this report does start a model turn.
|
|
141
|
+
// Genuinely append-only delivery needs pi.sendMessage({ customType, … },
|
|
142
|
+
// { triggerTurn: false }) plus a message renderer — a UX change, not made here.
|
|
142
143
|
const appendOnly: SendUserMessageOptions = { triggerTurn: false };
|
|
143
144
|
await pi.sendUserMessage(markdown, appendOnly);
|
|
144
145
|
const completed = outcomes.filter((item) => item.status === "done").length;
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
import type { MfPlanInfo } from "../moa/planInfo.ts";
|
|
4
4
|
import type { ImplementationHandoff } from "../moa/implementationRetry.ts";
|
|
5
|
+
import { PLAN_ONLY_REGISTERED_TOOLS } from "./tools/shared.ts";
|
|
5
6
|
|
|
6
7
|
export interface PlanModeState {
|
|
7
8
|
enabled: boolean;
|
|
@@ -10,10 +11,26 @@ export interface PlanModeState {
|
|
|
10
11
|
needsExitReminder?: boolean;
|
|
11
12
|
moaInfo?: MfPlanInfo;
|
|
12
13
|
implementationHandoff?: ImplementationHandoff;
|
|
14
|
+
/** Active loadout captured when plan mode was entered, so exit restores it. */
|
|
15
|
+
toolsBeforePlanMode?: string[];
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export const MAX_PERSISTED_STATE_BYTES = 512 * 1024;
|
|
16
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A snapshot containing plan-only tools was taken while plan mode was already
|
|
22
|
+
* live (pre-fix sessions, or a transcript-restored loadout). Restoring it would
|
|
23
|
+
* re-apply the read-only set after approval, so reject it and let the caller
|
|
24
|
+
* fall back to the registered loadout.
|
|
25
|
+
*/
|
|
26
|
+
export function validateToolLoadoutSnapshot(value: unknown): string[] | undefined {
|
|
27
|
+
if (!Array.isArray(value)) return undefined;
|
|
28
|
+
const names = value.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
29
|
+
if (names.length === 0) return undefined;
|
|
30
|
+
if (names.some((name) => PLAN_ONLY_REGISTERED_TOOLS.includes(name))) return undefined;
|
|
31
|
+
return [...new Set(names)];
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
export function serializePlanModeState(state: PlanModeState): { state: PlanModeState; serialized: string } {
|
|
18
35
|
let persisted = state.moaInfo?.proposerPlans
|
|
19
36
|
? { ...state, moaInfo: { ...state.moaInfo, proposerPlans: undefined } }
|
package/src/planning/planFile.ts
CHANGED
|
@@ -136,7 +136,7 @@ export function fallbackPlanName(): string {
|
|
|
136
136
|
return generateWordSlug();
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
export type RepoPlanFileKind = "plan-prompt" | "plan" | "verification" | "criteria";
|
|
139
|
+
export type RepoPlanFileKind = "plan-prompt" | "plan" | "verification" | "criteria" | "verification-handoff";
|
|
140
140
|
|
|
141
141
|
function repoPlanSuffix(kind: RepoPlanFileKind): string {
|
|
142
142
|
// `__verification` and `__criteria` never collide with the `/mf-plan-implement`
|
|
@@ -144,6 +144,7 @@ function repoPlanSuffix(kind: RepoPlanFileKind): string {
|
|
|
144
144
|
if (kind === "plan-prompt") return "__plan-prompt";
|
|
145
145
|
if (kind === "verification") return "__verification";
|
|
146
146
|
if (kind === "criteria") return "__criteria";
|
|
147
|
+
if (kind === "verification-handoff") return "__verification-handoff";
|
|
147
148
|
return "__plan";
|
|
148
149
|
}
|
|
149
150
|
|
package/src/planning/planMode.ts
CHANGED
|
@@ -37,10 +37,11 @@ import { showCancelOverlay } from "../ui/cancelOverlay.ts";
|
|
|
37
37
|
import { UI_TICK_MS } from "../ui/chrome.ts";
|
|
38
38
|
import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
39
39
|
import { showObserveOverlay, type ObserveSession } from "../ui/observeOverlay.ts";
|
|
40
|
+
import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
|
|
40
41
|
import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTracker, isAskUserQuestionInstalled } from "./askUserQuestion.ts";
|
|
41
42
|
import { buildPlanModeExitInstructions, buildPlanModeInstructions, buildPlanModeReentryInstructions } from "./instructions.ts";
|
|
42
|
-
import { latestPlanModeStateEntry, PlanModeStatePersistence } from "./modeState.ts";
|
|
43
|
-
import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug } from "./planFile.ts";
|
|
43
|
+
import { latestPlanModeStateEntry, PlanModeStatePersistence, validateToolLoadoutSnapshot } from "./modeState.ts";
|
|
44
|
+
import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug, slugifyPlanName } from "./planFile.ts";
|
|
44
45
|
import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS } from "./tools/shared.ts";
|
|
45
46
|
|
|
46
47
|
/** Trailing chars of the implementer report kept for the verifier's evidence. */
|
|
@@ -168,6 +169,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
168
169
|
needsExitReminder,
|
|
169
170
|
moaInfo: activeRunMoaInfo,
|
|
170
171
|
implementationHandoff,
|
|
172
|
+
toolsBeforePlanMode,
|
|
171
173
|
});
|
|
172
174
|
const footerState = (ctx: ExtensionContext): MoaFooterState => {
|
|
173
175
|
if (footerError) return "error";
|
|
@@ -218,6 +220,21 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
218
220
|
const customTools = PLAN_MODE_CUSTOM_TOOLS.filter((name) => name !== ASK_USER_QUESTION_TOOL_NAME || isAskUserQuestionInstalled(pi));
|
|
219
221
|
return [...new Set([...filtered, ...customTools])];
|
|
220
222
|
};
|
|
223
|
+
/**
|
|
224
|
+
* Every registered tool except the plan-only ones — the safe fallback when no
|
|
225
|
+
* trustworthy snapshot exists. Note this can restore a tool the user had
|
|
226
|
+
* deliberately deactivated; that is strictly better than leaving the
|
|
227
|
+
* implementer read-only, and only affects sessions saved before this field
|
|
228
|
+
* existed. Deduped because registration re-adds already-present tools.
|
|
229
|
+
*/
|
|
230
|
+
const fullLoadoutFallback = (): string[] => {
|
|
231
|
+
const registered = [...new Set(
|
|
232
|
+
pi.getAllTools().map((tool) => tool.name).filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)),
|
|
233
|
+
)];
|
|
234
|
+
// An empty registered list would strip every tool; prefer the active set.
|
|
235
|
+
if (registered.length > 0) return registered;
|
|
236
|
+
return [...new Set(pi.getActiveTools().filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)))];
|
|
237
|
+
};
|
|
221
238
|
const applyReadOnlyProviderEnv = (): void => {
|
|
222
239
|
if (providerEnvBeforePlanMode === undefined) {
|
|
223
240
|
providerEnvBeforePlanMode = {};
|
|
@@ -234,7 +251,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
234
251
|
providerEnvBeforePlanMode = undefined;
|
|
235
252
|
};
|
|
236
253
|
const enablePlanModeTools = (): void => {
|
|
237
|
-
if (toolsBeforePlanMode === undefined)
|
|
254
|
+
if (toolsBeforePlanMode === undefined) {
|
|
255
|
+
// 0.86.0 restores tools from the transcript before session_start, and a
|
|
256
|
+
// leaked plan-only set is possible mid-session, so an "active" set that
|
|
257
|
+
// contains plan-only tools is the plan loadout, not the user's.
|
|
258
|
+
const active = pi.getActiveTools();
|
|
259
|
+
toolsBeforePlanMode = active.some((name) => PLAN_ONLY_REGISTERED_TOOLS.includes(name))
|
|
260
|
+
? fullLoadoutFallback()
|
|
261
|
+
: active;
|
|
262
|
+
}
|
|
238
263
|
applyReadOnlyProviderEnv();
|
|
239
264
|
pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
|
|
240
265
|
};
|
|
@@ -356,7 +381,11 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
356
381
|
return lastReentryState;
|
|
357
382
|
};
|
|
358
383
|
async function saveSubmittedPlanPrompt(ctx: ExtensionContext, prompt: string): Promise<void> {
|
|
359
|
-
planRepoSlug = await
|
|
384
|
+
planRepoSlug = await withWorkingOverlay(
|
|
385
|
+
ctx,
|
|
386
|
+
planNamingOverlay("plan", () => slugifyPlanName(prompt)),
|
|
387
|
+
(signal) => summarizePlanPromptName(ctx, prompt, { signal }),
|
|
388
|
+
);
|
|
360
389
|
persistState();
|
|
361
390
|
saveRepoPlanFile(prompt, ctx.cwd, planRepoSlug, "plan-prompt");
|
|
362
391
|
}
|
|
@@ -397,7 +426,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
397
426
|
Implement: implementationHandoff.model,
|
|
398
427
|
Verify: implementationHandoff.verifier,
|
|
399
428
|
});
|
|
400
|
-
widget.switchToImplementing(implementationHandoff.model, "implementing plan
|
|
429
|
+
widget.switchToImplementing(implementationHandoff.model, "implementing plan", implementationHandoff.thinking);
|
|
401
430
|
activeProgressWidget = widget;
|
|
402
431
|
}
|
|
403
432
|
if (ctx) updateStatus(ctx);
|
|
@@ -625,6 +654,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
625
654
|
resetPlanSlug();
|
|
626
655
|
if (pi.getFlag("mf-plan") === true) planModeEnabled = true;
|
|
627
656
|
const restored = latestPlanModeStateEntry(ctx.sessionManager.getEntries());
|
|
657
|
+
const restoredPlanModeWasEnabled = restored?.enabled === true;
|
|
628
658
|
if (restored) {
|
|
629
659
|
planModeEnabled = restored.enabled ?? planModeEnabled;
|
|
630
660
|
needsExitReminder = restored.needsExitReminder ?? false;
|
|
@@ -632,10 +662,21 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
632
662
|
planRepoSlug = isValidPlanSlug(restored.repoPlanSlug) ? restored.repoPlanSlug : undefined;
|
|
633
663
|
activeRunMoaInfo = validateMfPlanInfo(restored.moaInfo);
|
|
634
664
|
implementationHandoff = validateImplementationHandoff(restored.implementationHandoff);
|
|
665
|
+
toolsBeforePlanMode = validateToolLoadoutSnapshot(restored.toolsBeforePlanMode);
|
|
635
666
|
}
|
|
636
667
|
if (planModeEnabled) {
|
|
637
668
|
lastReentryState = getPlan() !== null;
|
|
669
|
+
// A persisted plan-mode session had its loadout restored from the transcript
|
|
670
|
+
// before this handler ran (#9548), so the active set is the plan loadout, not
|
|
671
|
+
// the user's. Only the persisted snapshot or the registered loadout can be
|
|
672
|
+
// trusted here. The flag-only path (no entry) is excluded: there plan mode
|
|
673
|
+
// never ran, so the active set really is the user's.
|
|
674
|
+
const repairedLoadoutSnapshot = restoredPlanModeWasEnabled && toolsBeforePlanMode === undefined;
|
|
675
|
+
if (repairedLoadoutSnapshot) toolsBeforePlanMode = fullLoadoutFallback();
|
|
638
676
|
enablePlanModeTools();
|
|
677
|
+
// Re-persist the restored (and, if needed, repaired) snapshot so later
|
|
678
|
+
// resumes of this session see the same trustworthy loadout.
|
|
679
|
+
if (restoredPlanModeWasEnabled) persistState();
|
|
639
680
|
} else {
|
|
640
681
|
deactivatePlanOnlyTools();
|
|
641
682
|
}
|
|
@@ -7,8 +7,9 @@ import { stripSynthSections } from "../../moa/verdicts.ts";
|
|
|
7
7
|
import { FOLLOW_UP } from "../../shared/modelRefs.ts";
|
|
8
8
|
import type { MfPlanInfo, PlanReviewDecision } from "../../moa/planInfo.ts";
|
|
9
9
|
import { showPlanReview } from "../../ui/planReviewOverlay.ts";
|
|
10
|
+
import { planNamingOverlay, withWorkingOverlay } from "../../ui/workingOverlay.ts";
|
|
10
11
|
import { ASK_USER_QUESTION_PENDING_MESSAGE } from "../askUserQuestion.ts";
|
|
11
|
-
import { getPlan, getPlanFilePath, saveRepoPlanFile, writePlan } from "../planFile.ts";
|
|
12
|
+
import { getPlan, getPlanFilePath, saveRepoPlanFile, slugifyPlanName, writePlan } from "../planFile.ts";
|
|
12
13
|
|
|
13
14
|
import type { ImplementationHandoff } from "../../moa/implementationRetry.ts";
|
|
14
15
|
|
|
@@ -98,7 +99,11 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
if (!host.getPlanRepoSlug()) {
|
|
101
|
-
host.setPlanRepoSlug(await
|
|
102
|
+
host.setPlanRepoSlug(await withWorkingOverlay(
|
|
103
|
+
ctx,
|
|
104
|
+
planNamingOverlay("plan", () => slugifyPlanName(plan)),
|
|
105
|
+
(signal) => summarizePlanPromptName(ctx, plan, { signal }),
|
|
106
|
+
));
|
|
102
107
|
host.persistState();
|
|
103
108
|
}
|
|
104
109
|
|
|
@@ -6,6 +6,7 @@ import { Type } from "typebox";
|
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
|
|
8
8
|
import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../../agents/authoritative.ts";
|
|
9
|
+
import { loadMoaConfig } from "../../config/settings.ts";
|
|
9
10
|
import { discoverAgents, parseAgentFile } from "../../agents/discovery.ts";
|
|
10
11
|
import { modelExtensionOptions } from "../../moa/modelRuntime.ts";
|
|
11
12
|
import { CancelRun, type CancelRowExtras, type CancelSession } from "../../runtime/cancelRun.ts";
|
|
@@ -150,6 +151,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
150
151
|
// Parallel mode — each task gets its own abort signal (combined
|
|
151
152
|
// with pi's turn signal) so F4 can kill one stuck agent
|
|
152
153
|
// while its siblings keep working.
|
|
154
|
+
const maxConcurrency = loadMoaConfig().maxConcurrentAgents;
|
|
153
155
|
const tasks = params.tasks!;
|
|
154
156
|
const run = new CancelRun();
|
|
155
157
|
const extras: CancelRowExtras[] = tasks.map(() => ({}));
|
|
@@ -202,6 +204,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
202
204
|
loopCount: activityLoopCount(result.activity, histories[index]),
|
|
203
205
|
};
|
|
204
206
|
},
|
|
207
|
+
{ maxConcurrency },
|
|
205
208
|
);
|
|
206
209
|
|
|
207
210
|
// resolveOnAbort makes the runner resolve on abort; rethrow so a
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { AssistantMessage, Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AssistantMessage, Message, ToolCall } from "@earendil-works/pi-ai";
|
|
3
3
|
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
4
4
|
import { StreamingWordCounter } from "../activityMeter.ts";
|
|
5
|
-
import { isNonnegativeFiniteNumber, isValidContentIndex
|
|
5
|
+
import { isNonnegativeFiniteNumber, isValidContentIndex } from "./wire.ts";
|
|
6
6
|
|
|
7
7
|
/** Max length of a one-line tool-activity description surfaced for progress UIs. */
|
|
8
8
|
const ACTIVITY_MAX = 80;
|
|
@@ -94,6 +94,19 @@ export class OutputActivityTracker {
|
|
|
94
94
|
|
|
95
95
|
type AssistantContentPart = AssistantMessage["content"][number];
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The only fields `apply` reads. Declared structurally rather than as the wire
|
|
99
|
+
* union so both the JSON-protocol delta and the in-process
|
|
100
|
+
* `MessageUpdateEvent["assistantMessageEvent"]` are assignable — those two
|
|
101
|
+
* diverged when JSON `toolcall_start` gained required `id`/`toolName` (#7953).
|
|
102
|
+
*/
|
|
103
|
+
export interface PartialAssistantDelta {
|
|
104
|
+
type: string;
|
|
105
|
+
contentIndex?: number;
|
|
106
|
+
delta?: string;
|
|
107
|
+
toolCall?: ToolCall;
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
/**
|
|
98
111
|
* Rebuilds the in-flight assistant message from streamed deltas.
|
|
99
112
|
*
|
|
@@ -111,19 +124,22 @@ export class PartialAssistantAssembler {
|
|
|
111
124
|
this.#parts = [];
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
apply(assistantEvent:
|
|
127
|
+
apply(assistantEvent: PartialAssistantDelta | undefined): void {
|
|
115
128
|
if (!assistantEvent) return;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
129
|
+
const contentIndex = assistantEvent.contentIndex;
|
|
130
|
+
// Every handled delta kind is content-indexed, so an absent or out-of-range
|
|
131
|
+
// index is a no-op — same net effect as the previous `in` check.
|
|
132
|
+
if (!isValidContentIndex(contentIndex)) return;
|
|
133
|
+
if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
|
|
134
|
+
const part = this.#parts[contentIndex];
|
|
119
135
|
if (part?.type === "text") part.text += assistantEvent.delta;
|
|
120
|
-
else this.#parts[
|
|
121
|
-
} else if (assistantEvent.type === "thinking_delta") {
|
|
122
|
-
const part = this.#parts[
|
|
136
|
+
else this.#parts[contentIndex] = { type: "text", text: assistantEvent.delta };
|
|
137
|
+
} else if (assistantEvent.type === "thinking_delta" && typeof assistantEvent.delta === "string") {
|
|
138
|
+
const part = this.#parts[contentIndex];
|
|
123
139
|
if (part?.type === "thinking") part.thinking += assistantEvent.delta;
|
|
124
|
-
else this.#parts[
|
|
125
|
-
} else if (assistantEvent.type === "toolcall_end") {
|
|
126
|
-
this.#parts[
|
|
140
|
+
else this.#parts[contentIndex] = { type: "thinking", thinking: assistantEvent.delta };
|
|
141
|
+
} else if (assistantEvent.type === "toolcall_end" && assistantEvent.toolCall) {
|
|
142
|
+
this.#parts[contentIndex] = assistantEvent.toolCall;
|
|
127
143
|
}
|
|
128
144
|
}
|
|
129
145
|
|
|
@@ -38,6 +38,7 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
|
38
38
|
items: TIn[],
|
|
39
39
|
concurrency: number,
|
|
40
40
|
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
41
|
+
onStart?: (index: number) => void,
|
|
41
42
|
): Promise<TOut[]> {
|
|
42
43
|
if (items.length === 0) return [];
|
|
43
44
|
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
@@ -47,6 +48,7 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
|
47
48
|
while (true) {
|
|
48
49
|
const current = nextIndex++;
|
|
49
50
|
if (current >= items.length) return;
|
|
51
|
+
onStart?.(current);
|
|
50
52
|
results[current] = await fn(items[current], current);
|
|
51
53
|
}
|
|
52
54
|
});
|
package/src/runtime/runner.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as path from "node:path";
|
|
|
10
10
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
11
11
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { AgentConfig } from "../agents/discovery.ts";
|
|
13
|
+
import { normalizeMaxConcurrentAgents } from "../config/settings.ts";
|
|
13
14
|
import type { ThinkingLevel } from "../shared/modelRefs.ts";
|
|
14
15
|
import { OutputActivityTracker, PartialAssistantAssembler, formatToolActivity } from "./activityTracking.ts";
|
|
15
16
|
import { escalateKill, mapWithConcurrencyLimit, trackedProcesses } from "./processPool.ts";
|
|
@@ -24,7 +25,6 @@ import {
|
|
|
24
25
|
} from "./wire.ts";
|
|
25
26
|
|
|
26
27
|
const MAX_PARALLEL_TASKS = 8;
|
|
27
|
-
const MAX_CONCURRENCY = 5;
|
|
28
28
|
const CHILD_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
29
29
|
const MAX_DIAGNOSTIC_CHARS = 65_536;
|
|
30
30
|
const READ_ONLY_AGENT_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
@@ -94,8 +94,9 @@ export interface RunSingleAgentOptions {
|
|
|
94
94
|
* Called on every streamed turn boundary (each `message_end`) with the live,
|
|
95
95
|
* in-progress result. Lets callers observe running usage — notably
|
|
96
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.
|
|
98
|
-
*
|
|
97
|
+
* widget can show a growing context-usage bar while the agent works.
|
|
98
|
+
* `contextTokens` advances from `message_update` usage, authoritative
|
|
99
|
+
* `message_end` usage, and the stderr beacon; see
|
|
99
100
|
* docs/handoff-bridge-live-context.md.
|
|
100
101
|
*/
|
|
101
102
|
onProgress?: (result: SingleResult) => void;
|
|
@@ -249,6 +250,23 @@ export async function runSingleAgent(
|
|
|
249
250
|
outputActivity.messageUpdate(event.assistantMessageEvent);
|
|
250
251
|
partialMessage.apply(event.assistantMessageEvent);
|
|
251
252
|
currentResult.partialAssistant = partialMessage.snapshot();
|
|
253
|
+
// Interim context reading. Providers that run a whole task as one turn emit a
|
|
254
|
+
// single message_end, so without this the CTX bar sits at 0% for the run.
|
|
255
|
+
// A malformed or absent reading is ignored rather than rejected upstream, so a
|
|
256
|
+
// bad number never costs the run its text deltas.
|
|
257
|
+
// "beacon" source: never lowers a value and never overrides an authoritative
|
|
258
|
+
// message_end reading for the same turn.
|
|
259
|
+
const liveTotal = event.usage?.totalTokens;
|
|
260
|
+
if (isValidContextTokens(liveTotal)) {
|
|
261
|
+
const reconciled = reconcileContextTokens(
|
|
262
|
+
currentResult.usage.contextTokens,
|
|
263
|
+
contextUsageIsAuthoritative,
|
|
264
|
+
liveTotal,
|
|
265
|
+
"beacon",
|
|
266
|
+
);
|
|
267
|
+
currentResult.usage.contextTokens = reconciled.contextTokens;
|
|
268
|
+
contextUsageIsAuthoritative = reconciled.authoritative;
|
|
269
|
+
}
|
|
252
270
|
emitUpdate();
|
|
253
271
|
}
|
|
254
272
|
|
|
@@ -413,6 +431,13 @@ export interface ModelParallelAgentTask extends ParallelAgentTask {
|
|
|
413
431
|
model: string;
|
|
414
432
|
}
|
|
415
433
|
|
|
434
|
+
export interface ParallelRunOptions {
|
|
435
|
+
/** Max child processes in flight. Normalized here, so an omitted or malformed value falls back to the safe default. */
|
|
436
|
+
maxConcurrency?: number;
|
|
437
|
+
/** Called once a queued task acquires a process-pool slot, immediately before it begins. */
|
|
438
|
+
onStart?: (index: number) => void;
|
|
439
|
+
}
|
|
440
|
+
|
|
416
441
|
/** Run multiple agents in parallel with concurrency limit. */
|
|
417
442
|
export async function runParallelAgents(
|
|
418
443
|
defaultCwd: string,
|
|
@@ -422,11 +447,14 @@ export async function runParallelAgents(
|
|
|
422
447
|
onUpdate: ((partial: AgentToolResult<unknown>) => void) | undefined,
|
|
423
448
|
onEach?: (index: number, result: SingleResult) => void,
|
|
424
449
|
onProgress?: (index: number, result: SingleResult) => void,
|
|
450
|
+
options: ParallelRunOptions = {},
|
|
425
451
|
): Promise<SingleResult[]> {
|
|
426
452
|
if (tasks.length > MAX_PARALLEL_TASKS) {
|
|
427
453
|
throw new Error(`Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`);
|
|
428
454
|
}
|
|
429
455
|
|
|
456
|
+
const maxConcurrency = normalizeMaxConcurrentAgents(options.maxConcurrency);
|
|
457
|
+
|
|
430
458
|
const allResults: SingleResult[] = new Array(tasks.length);
|
|
431
459
|
|
|
432
460
|
for (let i = 0; i < tasks.length; i++) {
|
|
@@ -441,10 +469,12 @@ export async function runParallelAgents(
|
|
|
441
469
|
};
|
|
442
470
|
}
|
|
443
471
|
|
|
472
|
+
const startedIndices = new Set<number>();
|
|
473
|
+
|
|
444
474
|
const emitParallelUpdate = () => {
|
|
445
475
|
if (onUpdate) {
|
|
446
|
-
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
447
476
|
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
|
477
|
+
const running = startedIndices.size - done;
|
|
448
478
|
onUpdate({
|
|
449
479
|
details: undefined,
|
|
450
480
|
content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
|
|
@@ -452,10 +482,10 @@ export async function runParallelAgents(
|
|
|
452
482
|
}
|
|
453
483
|
};
|
|
454
484
|
|
|
455
|
-
const results = await mapWithConcurrencyLimit(tasks,
|
|
485
|
+
const results = await mapWithConcurrencyLimit(tasks, maxConcurrency, async (t, index) => {
|
|
456
486
|
const taskSignal = t.signal ?? signal;
|
|
457
487
|
if (taskSignal?.aborted) {
|
|
458
|
-
// Cancelled while queued behind
|
|
488
|
+
// Cancelled while queued behind the configured concurrency limit — never spawn.
|
|
459
489
|
const placeholder = cancelledPlaceholderResult(t.agent, t.task);
|
|
460
490
|
allResults[index] = placeholder;
|
|
461
491
|
emitParallelUpdate();
|
|
@@ -483,6 +513,9 @@ export async function runParallelAgents(
|
|
|
483
513
|
emitParallelUpdate();
|
|
484
514
|
onEach?.(index, result);
|
|
485
515
|
return result;
|
|
516
|
+
}, (index) => {
|
|
517
|
+
startedIndices.add(index);
|
|
518
|
+
options.onStart?.(index);
|
|
486
519
|
});
|
|
487
520
|
|
|
488
521
|
return results;
|
|
@@ -500,15 +533,18 @@ export async function runParallelAgentsWithModels(
|
|
|
500
533
|
signal: AbortSignal | undefined,
|
|
501
534
|
onEach: ((index: number, result: SingleResult) => void) | undefined,
|
|
502
535
|
onProgress?: (index: number, result: SingleResult) => void,
|
|
536
|
+
options: ParallelRunOptions = {},
|
|
503
537
|
): Promise<SingleResult[]> {
|
|
504
538
|
if (tasks.length > MAX_PARALLEL_TASKS) {
|
|
505
539
|
throw new Error(`Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`);
|
|
506
540
|
}
|
|
507
541
|
|
|
508
|
-
const
|
|
542
|
+
const maxConcurrency = normalizeMaxConcurrentAgents(options.maxConcurrency);
|
|
543
|
+
|
|
544
|
+
const results = await mapWithConcurrencyLimit(tasks, maxConcurrency, async (t, index) => {
|
|
509
545
|
const taskSignal = t.signal ?? signal;
|
|
510
546
|
if (taskSignal?.aborted) {
|
|
511
|
-
// Cancelled while queued behind
|
|
547
|
+
// Cancelled while queued behind the configured concurrency limit — never spawn.
|
|
512
548
|
const placeholder = cancelledPlaceholderResult(t.agent, t.task);
|
|
513
549
|
onEach?.(index, placeholder);
|
|
514
550
|
return placeholder;
|
|
@@ -532,7 +568,7 @@ export async function runParallelAgentsWithModels(
|
|
|
532
568
|
);
|
|
533
569
|
onEach?.(index, result);
|
|
534
570
|
return result;
|
|
535
|
-
});
|
|
571
|
+
}, options.onStart);
|
|
536
572
|
|
|
537
573
|
return results;
|
|
538
574
|
}
|
package/src/runtime/wire.ts
CHANGED
|
@@ -71,6 +71,17 @@ function isWireMessage(value: unknown): boolean {
|
|
|
71
71
|
return typeof value.content === "string"
|
|
72
72
|
|| (Array.isArray(value.content) && value.content.every((part) => isTextContent(part) || isImageContent(part)));
|
|
73
73
|
}
|
|
74
|
+
// 0.86.0 children emit a system message_start/message_end pair carrying the
|
|
75
|
+
// prompt and tool-loadout declaration. It never reaches `messages` (isLlmMessage
|
|
76
|
+
// filters it) and both trackers ignore non-assistant roles, but rejecting it
|
|
77
|
+
// here put two spurious parse errors at the head of every failure report.
|
|
78
|
+
// The pair arrives before any assistant stream, so `partialMessage.start()`
|
|
79
|
+
// clearing the in-flight partial on a non-assistant message_start loses
|
|
80
|
+
// nothing today — worth re-checking if a future protocol interleaves them.
|
|
81
|
+
if (value.role === "system") {
|
|
82
|
+
return typeof value.content === "string"
|
|
83
|
+
|| (Array.isArray(value.content) && value.content.every((part) => isTextContent(part)));
|
|
84
|
+
}
|
|
74
85
|
if (value.role === "assistant") {
|
|
75
86
|
return Array.isArray(value.content)
|
|
76
87
|
&& value.content.every((part) => isTextContent(part) || isThinkingContent(part) || isToolCall(part));
|
package/src/ui/cancelOverlay.ts
CHANGED
|
@@ -150,7 +150,7 @@ class CancelOverlayComponent implements Component {
|
|
|
150
150
|
: spin;
|
|
151
151
|
const stateText =
|
|
152
152
|
agent.state === "running" ? "running"
|
|
153
|
-
: agent.state === "cancelling" ? "cancelling
|
|
153
|
+
: agent.state === "cancelling" ? "cancelling"
|
|
154
154
|
: agent.state;
|
|
155
155
|
const extras = this.session.getExtras?.(i);
|
|
156
156
|
const bar = extras && (extras.contextTokens !== undefined || extras.contextWindow !== undefined)
|
package/src/ui/moaModelPicker.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
import { PLAN_OVERLAY_OPTIONS } from "./menu.ts";
|
|
45
45
|
import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
|
|
46
46
|
import { smartTruncateModelLabel } from "./modelLabel.ts";
|
|
47
|
+
import { proposerDiversityWarning } from "../moa/panelDependence.ts";
|
|
47
48
|
import { getModelCatalogue } from "../config/modelCatalogue.ts";
|
|
48
49
|
import {
|
|
49
50
|
loadMoaConfig,
|
|
@@ -244,6 +245,11 @@ class MoaModelPickerComponent implements Component {
|
|
|
244
245
|
return this.proposerRefs.reduce((n, ref) => (ref ? n + 1 : n), 0);
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
private diversityWarning(): string | undefined {
|
|
249
|
+
const assigned = this.proposerRefs.filter((ref): ref is ModelRef => ref !== undefined);
|
|
250
|
+
return proposerDiversityWarning(assigned);
|
|
251
|
+
}
|
|
252
|
+
|
|
247
253
|
/**
|
|
248
254
|
* Ready to start once at least MIN_PROPOSERS proposer slots plus all three
|
|
249
255
|
* required roles hold explicit assignments. Counts assigned slots, not
|
|
@@ -587,7 +593,11 @@ class MoaModelPickerComponent implements Component {
|
|
|
587
593
|
slotRow("Verifier", detail("Verifier", this.verifierRef, this.verifierThinking), VERIFIER_ROW),
|
|
588
594
|
startRow,
|
|
589
595
|
];
|
|
590
|
-
const
|
|
596
|
+
const warning = this.diversityWarning();
|
|
597
|
+
const warningRows = warning
|
|
598
|
+
? wrapWords(warning, bodyWidth - 1).map((line) => row(th.fg("dim", line)))
|
|
599
|
+
: [];
|
|
600
|
+
const reviewRows = [loadRow, row(""), ...slotRows.slice(1, -1), row(""), ...warningRows, startRow];
|
|
591
601
|
const lines = [
|
|
592
602
|
topBorder,
|
|
593
603
|
row(th.fg("accent", "MoA Fusion Pre-flight")),
|