@underactive/pi-topping-moa-fusion 0.1.0 → 0.3.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 +54 -0
- package/README.md +54 -12
- 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 +16 -6
- package/src/debate/runDebate.ts +13 -4
- package/src/index.ts +14 -3
- 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 +13 -3
- 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 +117 -20
- package/src/moa/verificationCriteria.ts +1 -1
- package/src/opinion/opinionFanout.ts +5 -4
- package/src/opinion/runOpinion.ts +14 -13
- package/src/planning/contextEdits.ts +62 -0
- package/src/planning/modeState.ts +17 -0
- package/src/planning/planFile.ts +30 -12
- package/src/planning/planMode.ts +144 -11
- package/src/planning/tools/exitPlanMode.ts +7 -2
- package/src/planning/tools/mfPlanSubagent.ts +3 -0
- package/src/planning/tools/shared.ts +1 -0
- package/src/runtime/activityTracking.ts +28 -12
- package/src/runtime/processPool.ts +2 -0
- package/src/runtime/results.ts +18 -0
- package/src/runtime/runner.ts +45 -9
- package/src/runtime/wire.ts +11 -0
- package/src/spec/runSpec.ts +343 -0
- package/src/spec/specContract.ts +324 -0
- package/src/ui/cancelOverlay.ts +1 -1
- package/src/ui/moaModelPicker.ts +32 -4
- package/src/ui/moaProgressWidget.ts +114 -12
- package/src/ui/moaSetupOverlay.ts +51 -7
- package/src/ui/planReviewOverlay.ts +16 -8
- package/src/ui/rosterEditor.ts +18 -12
- package/src/ui/workingOverlay.ts +265 -0
package/src/planning/planMode.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type { Message
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Key } from "@earendil-works/pi-tui";
|
|
4
4
|
import type {
|
|
5
|
+
AgentBeforeSettleEvent,
|
|
5
6
|
AgentSettledEvent,
|
|
6
7
|
AgentStartEvent,
|
|
7
8
|
ExtensionAPI,
|
|
@@ -9,6 +10,7 @@ import type {
|
|
|
9
10
|
MessageEndEvent,
|
|
10
11
|
MessageStartEvent,
|
|
11
12
|
MessageUpdateEvent,
|
|
13
|
+
SessionBoundaryDraft,
|
|
12
14
|
ThemeColor,
|
|
13
15
|
ToolExecutionStartEvent,
|
|
14
16
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -37,11 +39,20 @@ import { showCancelOverlay } from "../ui/cancelOverlay.ts";
|
|
|
37
39
|
import { UI_TICK_MS } from "../ui/chrome.ts";
|
|
38
40
|
import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
39
41
|
import { showObserveOverlay, type ObserveSession } from "../ui/observeOverlay.ts";
|
|
42
|
+
import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
|
|
40
43
|
import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTracker, isAskUserQuestionInstalled } from "./askUserQuestion.ts";
|
|
44
|
+
import {
|
|
45
|
+
buildVerificationPendingMessage,
|
|
46
|
+
collectOmissionDrafts,
|
|
47
|
+
isPlanInstructionEntry,
|
|
48
|
+
isPlanModeInstructionText,
|
|
49
|
+
isVerificationPendingEntry,
|
|
50
|
+
type ScannedEntry,
|
|
51
|
+
} from "./contextEdits.ts";
|
|
41
52
|
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";
|
|
44
|
-
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";
|
|
53
|
+
import { latestPlanModeStateEntry, PlanModeStatePersistence, validateToolLoadoutSnapshot } from "./modeState.ts";
|
|
54
|
+
import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug, slugifyPlanName } from "./planFile.ts";
|
|
55
|
+
import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS, VERIFICATION_PENDING_CONTEXT_TYPE } from "./tools/shared.ts";
|
|
45
56
|
|
|
46
57
|
/** Trailing chars of the implementer report kept for the verifier's evidence. */
|
|
47
58
|
const IMPLEMENTATION_REPORT_MAX_CHARS = 8000;
|
|
@@ -85,6 +96,17 @@ function extractAssistantText(content: unknown): string {
|
|
|
85
96
|
return "";
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
// Existing tests and stale post-reload contexts may not expose getBranch;
|
|
100
|
+
// boundary handlers should become inert rather than throw in that case.
|
|
101
|
+
function readBranchEntries(ctx: ExtensionContext): readonly ScannedEntry[] {
|
|
102
|
+
try {
|
|
103
|
+
const manager = ctx.sessionManager as { getBranch?: () => readonly ScannedEntry[] };
|
|
104
|
+
return typeof manager.getBranch === "function" ? manager.getBranch() : [];
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
export function createPlanModeController(pi: ExtensionAPI) {
|
|
89
111
|
let planModeEnabled = false;
|
|
90
112
|
let toolsBeforePlanMode: string[] | undefined;
|
|
@@ -98,6 +120,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
98
120
|
let activeRunMoaInfo: MfPlanInfo | undefined;
|
|
99
121
|
let implementationHandoff: ImplementationHandoff | undefined;
|
|
100
122
|
let implementationPending = false;
|
|
123
|
+
let verificationInFlight = false;
|
|
124
|
+
const omittedEntryIds = new Set<string>();
|
|
101
125
|
let lastImplementationStopReason: string | undefined;
|
|
102
126
|
// The implementer's final assistant text, captured while pending so the
|
|
103
127
|
// verifier can be handed the implementer's own (untrusted) report. Capped to
|
|
@@ -168,6 +192,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
168
192
|
needsExitReminder,
|
|
169
193
|
moaInfo: activeRunMoaInfo,
|
|
170
194
|
implementationHandoff,
|
|
195
|
+
toolsBeforePlanMode,
|
|
171
196
|
});
|
|
172
197
|
const footerState = (ctx: ExtensionContext): MoaFooterState => {
|
|
173
198
|
if (footerError) return "error";
|
|
@@ -218,6 +243,21 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
218
243
|
const customTools = PLAN_MODE_CUSTOM_TOOLS.filter((name) => name !== ASK_USER_QUESTION_TOOL_NAME || isAskUserQuestionInstalled(pi));
|
|
219
244
|
return [...new Set([...filtered, ...customTools])];
|
|
220
245
|
};
|
|
246
|
+
/**
|
|
247
|
+
* Every registered tool except the plan-only ones — the safe fallback when no
|
|
248
|
+
* trustworthy snapshot exists. Note this can restore a tool the user had
|
|
249
|
+
* deliberately deactivated; that is strictly better than leaving the
|
|
250
|
+
* implementer read-only, and only affects sessions saved before this field
|
|
251
|
+
* existed. Deduped because registration re-adds already-present tools.
|
|
252
|
+
*/
|
|
253
|
+
const fullLoadoutFallback = (): string[] => {
|
|
254
|
+
const registered = [...new Set(
|
|
255
|
+
pi.getAllTools().map((tool) => tool.name).filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)),
|
|
256
|
+
)];
|
|
257
|
+
// An empty registered list would strip every tool; prefer the active set.
|
|
258
|
+
if (registered.length > 0) return registered;
|
|
259
|
+
return [...new Set(pi.getActiveTools().filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)))];
|
|
260
|
+
};
|
|
221
261
|
const applyReadOnlyProviderEnv = (): void => {
|
|
222
262
|
if (providerEnvBeforePlanMode === undefined) {
|
|
223
263
|
providerEnvBeforePlanMode = {};
|
|
@@ -234,7 +274,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
234
274
|
providerEnvBeforePlanMode = undefined;
|
|
235
275
|
};
|
|
236
276
|
const enablePlanModeTools = (): void => {
|
|
237
|
-
if (toolsBeforePlanMode === undefined)
|
|
277
|
+
if (toolsBeforePlanMode === undefined) {
|
|
278
|
+
// 0.86.0 restores tools from the transcript before session_start, and a
|
|
279
|
+
// leaked plan-only set is possible mid-session, so an "active" set that
|
|
280
|
+
// contains plan-only tools is the plan loadout, not the user's.
|
|
281
|
+
const active = pi.getActiveTools();
|
|
282
|
+
toolsBeforePlanMode = active.some((name) => PLAN_ONLY_REGISTERED_TOOLS.includes(name))
|
|
283
|
+
? fullLoadoutFallback()
|
|
284
|
+
: active;
|
|
285
|
+
}
|
|
238
286
|
applyReadOnlyProviderEnv();
|
|
239
287
|
pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
|
|
240
288
|
};
|
|
@@ -356,7 +404,11 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
356
404
|
return lastReentryState;
|
|
357
405
|
};
|
|
358
406
|
async function saveSubmittedPlanPrompt(ctx: ExtensionContext, prompt: string): Promise<void> {
|
|
359
|
-
planRepoSlug = await
|
|
407
|
+
planRepoSlug = await withWorkingOverlay(
|
|
408
|
+
ctx,
|
|
409
|
+
planNamingOverlay("plan", () => slugifyPlanName(prompt)),
|
|
410
|
+
(signal) => summarizePlanPromptName(ctx, prompt, { signal }),
|
|
411
|
+
);
|
|
360
412
|
persistState();
|
|
361
413
|
saveRepoPlanFile(prompt, ctx.cwd, planRepoSlug, "plan-prompt");
|
|
362
414
|
}
|
|
@@ -397,7 +449,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
397
449
|
Implement: implementationHandoff.model,
|
|
398
450
|
Verify: implementationHandoff.verifier,
|
|
399
451
|
});
|
|
400
|
-
widget.switchToImplementing(implementationHandoff.model, "implementing plan
|
|
452
|
+
widget.switchToImplementing(implementationHandoff.model, "implementing plan", implementationHandoff.thinking);
|
|
401
453
|
activeProgressWidget = widget;
|
|
402
454
|
}
|
|
403
455
|
if (ctx) updateStatus(ctx);
|
|
@@ -519,6 +571,63 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
519
571
|
activeProgressWidget?.updateRoleTranscript("Implement", implementationMessages);
|
|
520
572
|
}
|
|
521
573
|
},
|
|
574
|
+
onAgentBeforeSettle: (event: AgentBeforeSettleEvent, ctx: ExtensionContext) => {
|
|
575
|
+
try {
|
|
576
|
+
const branch = readBranchEntries(ctx);
|
|
577
|
+
const persistedOmissions = new Set(
|
|
578
|
+
branch
|
|
579
|
+
.filter((entry) => entry.type === "context_edit" && entry.replacement === null && typeof entry.targetId === "string")
|
|
580
|
+
.map((entry) => entry.targetId as string),
|
|
581
|
+
);
|
|
582
|
+
// A different extension can invalidate the combined boundary proposal.
|
|
583
|
+
// Forget locally proposed edits that did not reach the branch so they are
|
|
584
|
+
// retried rather than suppressed for the rest of this process.
|
|
585
|
+
for (const id of omittedEntryIds) {
|
|
586
|
+
if (!persistedOmissions.has(id)) omittedEntryIds.delete(id);
|
|
587
|
+
}
|
|
588
|
+
const drafts: SessionBoundaryDraft[] = [];
|
|
589
|
+
|
|
590
|
+
// Persist plan-instruction omissions after plan mode exits. This is
|
|
591
|
+
// additive to onContext's per-request filter and repairs older sessions.
|
|
592
|
+
if (!planModeEnabled) {
|
|
593
|
+
drafts.push(...collectOmissionDrafts(branch, isPlanInstructionEntry, omittedEntryIds));
|
|
594
|
+
}
|
|
595
|
+
if (!verificationInFlight) {
|
|
596
|
+
drafts.push(...collectOmissionDrafts(branch, isVerificationPendingEntry, omittedEntryIds));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const stopReason = lastImplementationStopReason;
|
|
600
|
+
// KEEP IN LOCKSTEP with onAgentSettled's verification branch below.
|
|
601
|
+
// exit_plan_mode marks implementation pending before the follow-up has
|
|
602
|
+
// run, so implementationTurns distinguishes that planning boundary.
|
|
603
|
+
const verificationWillDispatch = implementationPending
|
|
604
|
+
&& implementationTurns > 0
|
|
605
|
+
&& event.context.pendingMessages.length === 0
|
|
606
|
+
&& event.outcome !== "aborted"
|
|
607
|
+
&& event.outcome !== "error"
|
|
608
|
+
&& !implementationTurnFailed(stopReason)
|
|
609
|
+
&& stopReason !== "aborted"
|
|
610
|
+
&& implementationHandoff !== undefined
|
|
611
|
+
&& (implementationHandoff.verifier ?? loadMoaConfig().verifier) !== undefined;
|
|
612
|
+
if (verificationWillDispatch) {
|
|
613
|
+
drafts.push({
|
|
614
|
+
type: "custom_message",
|
|
615
|
+
customType: VERIFICATION_PENDING_CONTEXT_TYPE,
|
|
616
|
+
content: buildVerificationPendingMessage(implementationHandoff?.planFilePath),
|
|
617
|
+
display: false,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (drafts.length === 0) return undefined;
|
|
622
|
+
for (const draft of drafts) {
|
|
623
|
+
if (draft.type === "context_edit") omittedEntryIds.add(draft.targetId);
|
|
624
|
+
}
|
|
625
|
+
return { entries: [...event.entries, ...drafts] };
|
|
626
|
+
} catch {
|
|
627
|
+
// A thrown boundary handler could reject every proposed entry.
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
},
|
|
522
631
|
onAgentSettled: (_event: AgentSettledEvent, ctx: ExtensionContext) => {
|
|
523
632
|
updateStatus(ctx);
|
|
524
633
|
if (!implementationPending) return;
|
|
@@ -566,10 +675,13 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
566
675
|
// explicit guard is required before the verification branch.
|
|
567
676
|
if (stopReason === "aborted") return;
|
|
568
677
|
if (!implementationHandoff) return;
|
|
678
|
+
// KEEP IN LOCKSTEP with verificationWillDispatch in onAgentBeforeSettle.
|
|
569
679
|
const verifier = implementationHandoff.verifier ?? loadMoaConfig().verifier;
|
|
570
680
|
if (verifier) {
|
|
681
|
+
verificationInFlight = true;
|
|
571
682
|
void runImplementationVerification(ctx, moaRunHost, { ...implementationHandoff, verifier }, report)
|
|
572
|
-
.catch(reportFlowFailure("Verification"))
|
|
683
|
+
.catch(reportFlowFailure("Verification"))
|
|
684
|
+
.finally(() => { verificationInFlight = false; });
|
|
573
685
|
}
|
|
574
686
|
},
|
|
575
687
|
onContext: async (event: { messages: AgentMessage[] }) => {
|
|
@@ -578,9 +690,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
578
690
|
const msg = message as AgentMessage & { customType?: string };
|
|
579
691
|
if (msg.customType === PLAN_MODE_CONTEXT_TYPE || msg.customType === PLAN_EXIT_CONTEXT_TYPE) return false;
|
|
580
692
|
if (msg.role !== "user") return true;
|
|
581
|
-
|
|
582
|
-
if (Array.isArray(msg.content)) return !msg.content.some((part) => part.type === "text" && ((part as TextContent).text?.startsWith("[PLAN MODE ACTIVE]") || (part as TextContent).text?.startsWith("[PLAN MODE RE-ENTRY]")));
|
|
583
|
-
return true;
|
|
693
|
+
return !isPlanModeInstructionText(msg.content);
|
|
584
694
|
}) };
|
|
585
695
|
},
|
|
586
696
|
onBeforeAgentStart: async () => {
|
|
@@ -612,6 +722,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
612
722
|
activeCancelSession = undefined;
|
|
613
723
|
activeObserveSession = undefined;
|
|
614
724
|
implementationHandoff = undefined;
|
|
725
|
+
verificationInFlight = false;
|
|
726
|
+
omittedEntryIds.clear();
|
|
615
727
|
footerError = undefined;
|
|
616
728
|
lastRenderedFooter = undefined;
|
|
617
729
|
lastFooterCtx = undefined;
|
|
@@ -625,6 +737,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
625
737
|
resetPlanSlug();
|
|
626
738
|
if (pi.getFlag("mf-plan") === true) planModeEnabled = true;
|
|
627
739
|
const restored = latestPlanModeStateEntry(ctx.sessionManager.getEntries());
|
|
740
|
+
const restoredPlanModeWasEnabled = restored?.enabled === true;
|
|
628
741
|
if (restored) {
|
|
629
742
|
planModeEnabled = restored.enabled ?? planModeEnabled;
|
|
630
743
|
needsExitReminder = restored.needsExitReminder ?? false;
|
|
@@ -632,13 +745,31 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
632
745
|
planRepoSlug = isValidPlanSlug(restored.repoPlanSlug) ? restored.repoPlanSlug : undefined;
|
|
633
746
|
activeRunMoaInfo = validateMfPlanInfo(restored.moaInfo);
|
|
634
747
|
implementationHandoff = validateImplementationHandoff(restored.implementationHandoff);
|
|
748
|
+
toolsBeforePlanMode = validateToolLoadoutSnapshot(restored.toolsBeforePlanMode);
|
|
635
749
|
}
|
|
636
750
|
if (planModeEnabled) {
|
|
637
751
|
lastReentryState = getPlan() !== null;
|
|
752
|
+
// A persisted plan-mode session had its loadout restored from the transcript
|
|
753
|
+
// before this handler ran (#9548), so the active set is the plan loadout, not
|
|
754
|
+
// the user's. Only the persisted snapshot or the registered loadout can be
|
|
755
|
+
// trusted here. The flag-only path (no entry) is excluded: there plan mode
|
|
756
|
+
// never ran, so the active set really is the user's.
|
|
757
|
+
const repairedLoadoutSnapshot = restoredPlanModeWasEnabled && toolsBeforePlanMode === undefined;
|
|
758
|
+
if (repairedLoadoutSnapshot) toolsBeforePlanMode = fullLoadoutFallback();
|
|
638
759
|
enablePlanModeTools();
|
|
760
|
+
// Re-persist the restored (and, if needed, repaired) snapshot so later
|
|
761
|
+
// resumes of this session see the same trustworthy loadout.
|
|
762
|
+
if (restoredPlanModeWasEnabled) persistState();
|
|
639
763
|
} else {
|
|
640
764
|
deactivatePlanOnlyTools();
|
|
641
765
|
}
|
|
766
|
+
try {
|
|
767
|
+
if (collectOmissionDrafts(readBranchEntries(ctx), isVerificationPendingEntry, new Set()).length > 0) {
|
|
768
|
+
ctx.ui.notify("The previous session ended before plan verification finished — the last implementation is unverified. Run /mf-plan-implement to verify it.", "warning");
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
// A stale session-start context cannot be notified safely.
|
|
772
|
+
}
|
|
642
773
|
updateStatus(ctx);
|
|
643
774
|
startFooterTicker(ctx);
|
|
644
775
|
if (ctx.mode === "tui" && typeof ctx.ui.onTerminalInput === "function") {
|
|
@@ -653,6 +784,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
653
784
|
},
|
|
654
785
|
onSessionShutdown: (_event: { reason: string }) => {
|
|
655
786
|
implementationPending = false;
|
|
787
|
+
verificationInFlight = false;
|
|
788
|
+
omittedEntryIds.clear();
|
|
656
789
|
lastImplementationStopReason = undefined;
|
|
657
790
|
lastImplementationReport = undefined;
|
|
658
791
|
resetImplementationTranscript();
|
|
@@ -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
|
|
@@ -17,3 +17,4 @@ export const PLAN_ONLY_REGISTERED_TOOLS = ["write_plan", "exit_plan_mode", "mf_p
|
|
|
17
17
|
export const PLAN_SUBAGENT_NAMES = new Set(["moa-explore", "mf-plan"]);
|
|
18
18
|
export const PLAN_MODE_CONTEXT_TYPE = "mf-plan-context";
|
|
19
19
|
export const PLAN_EXIT_CONTEXT_TYPE = "mf-plan-exit";
|
|
20
|
+
export const VERIFICATION_PENDING_CONTEXT_TYPE = "mf-plan-verification-pending";
|
|
@@ -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/results.ts
CHANGED
|
@@ -92,6 +92,24 @@ export function getResultOutput(result: { exitCode: number; stopReason?: string;
|
|
|
92
92
|
return getFinalOutput(result.messages) || "(no output)";
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Longest failure message kept for a status row. Generous enough that a
|
|
97
|
+
* provider's JSON error payload survives whole for a progress table to wrap
|
|
98
|
+
* across several lines, bounded so a multi-KB stderr dump cannot crowd out the
|
|
99
|
+
* table it renders in.
|
|
100
|
+
*/
|
|
101
|
+
const MAX_STATUS_DETAIL_CHARS = 500;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collapse an agent failure into one bounded line for a progress row. Runs of
|
|
105
|
+
* whitespace — newlines included — are flattened because the row is laid out as
|
|
106
|
+
* a single line before its surface wraps it to the available width.
|
|
107
|
+
*/
|
|
108
|
+
export function statusDetail(output: string): string {
|
|
109
|
+
const collapsed = output.replace(/\s+/g, " ").trim();
|
|
110
|
+
return collapsed.length <= MAX_STATUS_DETAIL_CHARS ? collapsed : `${collapsed.slice(0, MAX_STATUS_DETAIL_CHARS - 1)}…`;
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
export function truncateOutput(output: string): string {
|
|
96
114
|
const maxBytes = 50 * 1024;
|
|
97
115
|
const bytes = Buffer.from(output, "utf8");
|
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));
|