@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
package/src/moa/fanout.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { getFinalOutput, isFailedResult } from "../runtime/results.ts";
|
|
|
7
7
|
import type { ModelParallelAgentTask } from "../runtime/runner.ts";
|
|
8
8
|
import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
9
9
|
import { activityLoopCount } from "../ui/agentStatus.ts";
|
|
10
|
+
import { showModelThinkingPicker } from "../ui/moaModelPicker.ts";
|
|
10
11
|
import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
11
12
|
import type { ObserveSession } from "../ui/observeOverlay.ts";
|
|
13
|
+
import { duplicateModelSlotCount, proposerDiversityWarning } from "./panelDependence.ts";
|
|
12
14
|
import { runWidgetFanout } from "./fanoutWiring.ts";
|
|
13
15
|
import { buildProposerRetryTask, buildProposerTask, looksLikePlan } from "./planlessRetry.ts";
|
|
14
16
|
import { modelExtensionOptions, resolveContextWindow } from "./modelRuntime.ts";
|
|
@@ -29,11 +31,12 @@ export interface FanoutPhaseOptions {
|
|
|
29
31
|
widget: MoaProgressWidget;
|
|
30
32
|
agents: AgentConfig[];
|
|
31
33
|
warnIfMutated(phase: string): Promise<void>;
|
|
34
|
+
maxConcurrency: number;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<FanoutPhaseResult> {
|
|
35
|
-
const { host, runContext, ctx, prompt, proposers, proposerThinking, session, widget, agents, warnIfMutated } = options;
|
|
36
|
-
widget.startFanout(proposers, proposerThinking);
|
|
38
|
+
const { host, runContext, ctx, prompt, proposers, proposerThinking, session, widget, agents, warnIfMutated, maxConcurrency } = options;
|
|
39
|
+
widget.startFanout(proposers, proposerThinking, maxConcurrency);
|
|
37
40
|
const observe: ObserveSession = {
|
|
38
41
|
title: "MoA fan-out",
|
|
39
42
|
phase: "fanout",
|
|
@@ -64,7 +67,7 @@ export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<Fanou
|
|
|
64
67
|
};
|
|
65
68
|
};
|
|
66
69
|
const runFanout = (tasks: ModelParallelAgentTask[], indexMap: (index: number) => number, currentRun: CancelRun) =>
|
|
67
|
-
runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models: proposers, indexMap });
|
|
70
|
+
runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models: proposers, indexMap, maxConcurrency });
|
|
68
71
|
|
|
69
72
|
// The planning contract travels inside the task text — the only channel
|
|
70
73
|
// guaranteed to survive a bridge's system-prompt override. The observer keeps
|
|
@@ -89,6 +92,114 @@ export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<Fanou
|
|
|
89
92
|
return { status: "cancelled", observe, previousObserveSession };
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
// A proposer cancelled from the cancel overlay must not silently drop out of
|
|
96
|
+
// the run: the user who killed it decides what replaces it, mirroring the
|
|
97
|
+
// synthesizer-failure precedent. A slot dropped here stays `cancelled` in
|
|
98
|
+
// results, so it is already excluded from `succeeded` — "continue without"
|
|
99
|
+
// needs no other handling. This runs after the cancel-all check and before
|
|
100
|
+
// the planless-retry pass, so a replacement that returns no plan then gets
|
|
101
|
+
// that retry for free.
|
|
102
|
+
const cancelledSlots = results.flatMap((r, i) => r.cancelled ? [i] : []);
|
|
103
|
+
if (cancelledSlots.length > 0 && ctx.hasUI) {
|
|
104
|
+
// Stop the stacked overlays and the sticky widget before the modal so the
|
|
105
|
+
// select/picker are not fighting them. session.run is already undefined
|
|
106
|
+
// (cleared in runWidgetFanout's finally), so ESC reaches the modal, not a
|
|
107
|
+
// reopened cancel overlay.
|
|
108
|
+
session.closeOverlay?.();
|
|
109
|
+
widget.stopWidget();
|
|
110
|
+
for (const index of cancelledSlots) {
|
|
111
|
+
while (true) {
|
|
112
|
+
const label = modelRefLabel(proposers[index]);
|
|
113
|
+
const choice = await ctx.ui.select(
|
|
114
|
+
`Proposer ${index + 1} (${label}) was cancelled — what next?`,
|
|
115
|
+
["Select a different model", "Continue without this proposer"],
|
|
116
|
+
);
|
|
117
|
+
if (choice !== "Select a different model") break;
|
|
118
|
+
const picked = await showModelThinkingPicker(
|
|
119
|
+
ctx,
|
|
120
|
+
host.currentThinkingLevel(),
|
|
121
|
+
`Replacement for Proposer ${index + 1}`,
|
|
122
|
+
proposers[index],
|
|
123
|
+
proposerThinking[index],
|
|
124
|
+
);
|
|
125
|
+
if (!picked) continue;
|
|
126
|
+
// Rewrite the shared phase/synthesis/observe arrays in place: the
|
|
127
|
+
// same object is held by the widget band, the cost resolution,
|
|
128
|
+
// synthesis, and the persisted activeRunMoaInfo.
|
|
129
|
+
proposers[index] = picked.ref;
|
|
130
|
+
proposerThinking[index] = picked.thinking;
|
|
131
|
+
host.persistState();
|
|
132
|
+
if (duplicateModelSlotCount(proposers, picked.ref) >= 2) {
|
|
133
|
+
const warning = proposerDiversityWarning(proposers);
|
|
134
|
+
if (warning) ctx.ui.notify(warning, "warning");
|
|
135
|
+
}
|
|
136
|
+
widget.replaceProposerModel(index, picked.ref, picked.thinking);
|
|
137
|
+
// Rewrite the observe row's identity, not just its messages, so F3
|
|
138
|
+
// shows the replacement and not the cancelled agent's corpse.
|
|
139
|
+
const observed = observe.agents[index];
|
|
140
|
+
if (observed) {
|
|
141
|
+
observed.label = picked.ref.id;
|
|
142
|
+
observed.model = picked.ref.id;
|
|
143
|
+
observed.task = proposerTask;
|
|
144
|
+
observed.messages = [];
|
|
145
|
+
observed.partial = undefined;
|
|
146
|
+
observed.state = "working";
|
|
147
|
+
}
|
|
148
|
+
const replaceRun = new CancelRun();
|
|
149
|
+
session.title = "MoA fan-out (replacement)";
|
|
150
|
+
session.getExtras = () => {
|
|
151
|
+
const status = widget.getStatus(index);
|
|
152
|
+
if (!status) return undefined;
|
|
153
|
+
return {
|
|
154
|
+
contextTokens: status.contextTokens,
|
|
155
|
+
contextWindow: resolveContextWindow(ctx, status.ref),
|
|
156
|
+
activity: status.activity,
|
|
157
|
+
loopCount: activityLoopCount(status.activity, status.activityHistory),
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
// The slot was killed, not planless — rerun the fresh proposer task.
|
|
161
|
+
const [replacement] = await runFanout(
|
|
162
|
+
[
|
|
163
|
+
{
|
|
164
|
+
agent: "moa-proposer",
|
|
165
|
+
task: proposerTask,
|
|
166
|
+
model: modelRefLabel(picked.ref),
|
|
167
|
+
thinking: picked.thinking,
|
|
168
|
+
...modelExtensionOptions(ctx, picked.ref),
|
|
169
|
+
signal: replaceRun.add(modelRefLabel(picked.ref)).signal,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
() => index,
|
|
173
|
+
replaceRun,
|
|
174
|
+
);
|
|
175
|
+
results[index] = replacement;
|
|
176
|
+
await warnIfMutated("MoA proposer replacement");
|
|
177
|
+
// Cancel ALL during a replacement ends the run, like the other exits.
|
|
178
|
+
if (replaceRun.cancelAllRequested) {
|
|
179
|
+
widget.stopWidget();
|
|
180
|
+
ctx.ui.notify("MoA run cancelled.");
|
|
181
|
+
return { status: "cancelled", observe, previousObserveSession };
|
|
182
|
+
}
|
|
183
|
+
// A cancelled or planless replacement re-asks the same two options
|
|
184
|
+
// until the user chooses to move on.
|
|
185
|
+
if (replacement.cancelled || isFailedResult(replacement)) {
|
|
186
|
+
session.closeOverlay?.();
|
|
187
|
+
widget.stopWidget();
|
|
188
|
+
ctx.ui.notify(
|
|
189
|
+
`Replacement proposer ${modelRefLabel(picked.ref)} did not produce a plan.`,
|
|
190
|
+
"warning",
|
|
191
|
+
);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// A prompt stopped the widget; re-show the table so the planless-retry pass
|
|
198
|
+
// and synthesis render into a mounted table even when the user dropped
|
|
199
|
+
// every cancelled slot.
|
|
200
|
+
widget.resumeTable();
|
|
201
|
+
}
|
|
202
|
+
|
|
92
203
|
const retryIndices: number[] = [];
|
|
93
204
|
for (let index = 0; index < results.length; index++) {
|
|
94
205
|
const result = results[index];
|
|
@@ -112,7 +223,7 @@ export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<Fanou
|
|
|
112
223
|
const ref = proposers[proposerIndex];
|
|
113
224
|
const label = modelRefLabel(ref);
|
|
114
225
|
const retryTask = buildProposerRetryTask(proposerTask, getFinalOutput(results[proposerIndex].messages));
|
|
115
|
-
widget.update(proposerIndex, "
|
|
226
|
+
widget.update(proposerIndex, "queued", "retrying: no plan produced");
|
|
116
227
|
const observed = observe.agents[proposerIndex];
|
|
117
228
|
if (observed) {
|
|
118
229
|
observed.state = "working";
|
package/src/moa/fanoutWiring.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface WidgetFanoutOptions {
|
|
|
19
19
|
observe: ObserveSession;
|
|
20
20
|
models: ModelRef[];
|
|
21
21
|
indexMap: (index: number) => number;
|
|
22
|
+
maxConcurrency: number;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -27,7 +28,7 @@ export interface WidgetFanoutOptions {
|
|
|
27
28
|
* settle/onProgress plumbing shared by every MoA fan-out variant.
|
|
28
29
|
*/
|
|
29
30
|
export async function runWidgetFanout(options: WidgetFanoutOptions): Promise<SingleResult[]> {
|
|
30
|
-
const { ctx, agents, tasks, run, session, widget, observe, models, indexMap } = options;
|
|
31
|
+
const { ctx, agents, tasks, run, session, widget, observe, models, indexMap, maxConcurrency } = options;
|
|
31
32
|
const unsubscribe = run.onChange(() => {
|
|
32
33
|
run.agents.forEach((agent, index) => {
|
|
33
34
|
if (agent.state === "cancelling") widget.update(indexMap(index), "cancelling");
|
|
@@ -80,6 +81,15 @@ export async function runWidgetFanout(options: WidgetFanoutOptions): Promise<Sin
|
|
|
80
81
|
observed.partial = result.partialAssistant;
|
|
81
82
|
}
|
|
82
83
|
},
|
|
84
|
+
{
|
|
85
|
+
maxConcurrency,
|
|
86
|
+
onStart: (index) => {
|
|
87
|
+
const widgetIndex = indexMap(index);
|
|
88
|
+
widget.update(widgetIndex, "working");
|
|
89
|
+
const observed = observe.agents[widgetIndex];
|
|
90
|
+
if (observed) observed.state = "working";
|
|
91
|
+
},
|
|
92
|
+
},
|
|
83
93
|
);
|
|
84
94
|
} finally {
|
|
85
95
|
unsubscribe();
|
|
@@ -234,7 +234,7 @@ export async function runImplementationRetryFlow(
|
|
|
234
234
|
|
|
235
235
|
if (selection.startsWith("Retry with")) {
|
|
236
236
|
host.markImplementationPending(ctx);
|
|
237
|
-
if (handoff.model) host.getActiveProgressWidget()?.switchToImplementing(handoff.model, "retrying implementation
|
|
237
|
+
if (handoff.model) host.getActiveProgressWidget()?.switchToImplementing(handoff.model, "retrying implementation", handoff.thinking);
|
|
238
238
|
const note = failureReason
|
|
239
239
|
? `The previous implementation attempt failed with: ${failureReason}. Review the current state of the code and continue implementing the approved plan.`
|
|
240
240
|
: "The previous implementation attempt failed. Review the current state of the code and continue implementing the approved plan.";
|
|
@@ -280,7 +280,7 @@ export async function runImplementationRetryFlow(
|
|
|
280
280
|
Implement: newSelection.ref,
|
|
281
281
|
Verify: updatedHandoff.verifier,
|
|
282
282
|
});
|
|
283
|
-
widget.switchToImplementing(newSelection.ref, "retrying implementation
|
|
283
|
+
widget.switchToImplementing(newSelection.ref, "retrying implementation", newSelection.thinking);
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
const note = `Switched implementation model to ${modelRefLabel(newSelection.ref)}. Review the current state of the code and continue implementing the approved plan.`;
|
package/src/moa/orchestration.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
|
|
4
|
+
import { loadMoaConfig } from "../config/settings.ts";
|
|
4
5
|
import { discoverAgents } from "../agents/discovery.ts";
|
|
5
6
|
import { cleanupProposalFiles, sweepStaleProposalFiles } from "../planning/planFile.ts";
|
|
6
7
|
import type { CancelSession } from "../runtime/cancelRun.ts";
|
|
@@ -59,6 +60,7 @@ export async function runMoaOrchestration(
|
|
|
59
60
|
try {
|
|
60
61
|
installShippedAgents();
|
|
61
62
|
sweepStaleProposalFiles();
|
|
63
|
+
const maxConcurrency = loadMoaConfig().maxConcurrentAgents;
|
|
62
64
|
const discovery = discoverAgents(ctx.cwd, "user");
|
|
63
65
|
const agents = withAuthoritativeMoaAgents(discovery.agents, shippedAgentsDir());
|
|
64
66
|
const tripwire = new MutationTripwire();
|
|
@@ -78,6 +80,7 @@ export async function runMoaOrchestration(
|
|
|
78
80
|
widget,
|
|
79
81
|
agents,
|
|
80
82
|
warnIfMutated,
|
|
83
|
+
maxConcurrency,
|
|
81
84
|
});
|
|
82
85
|
if (fanout.status !== "continue") return fanout.status;
|
|
83
86
|
const synthesis = await runSynthesisPhase(runContext, {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependence structure for the MoA proposer panel.
|
|
3
|
+
*
|
|
4
|
+
* Blinding invariant: nothing derived from a ModelRef (provider, id, label,
|
|
5
|
+
* family) may appear in synthesizer-facing output — only integers and
|
|
6
|
+
* proposerBlindedLabel slot numbers. Family-level clusters are excluded
|
|
7
|
+
* because they risk de-blinding a lopsided roster.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { modelRefLabel, proposerBlindedLabel, type ModelRef } from "../shared/modelRefs.ts";
|
|
11
|
+
|
|
12
|
+
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
|
13
|
+
return count === 1 ? singular : plural;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatClusterSentence(slots: number[]): string {
|
|
17
|
+
const numbers = slots.map((index) => proposerBlindedLabel(index).replace("Proposer ", ""));
|
|
18
|
+
if (numbers.length === 2) {
|
|
19
|
+
return `Proposers ${numbers[0]} and ${numbers[1]} share one model.`;
|
|
20
|
+
}
|
|
21
|
+
const last = numbers.pop();
|
|
22
|
+
return `Proposers ${numbers.join(", ")}, and ${last} share one model.`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Blinded one-paragraph note describing panel dependence for the synthesizer. */
|
|
26
|
+
export function buildPanelDependenceNote(fed: readonly { ref: ModelRef; originalIndex: number }[]): string {
|
|
27
|
+
const proposalCount = fed.length;
|
|
28
|
+
const clusters = new Map<string, number[]>();
|
|
29
|
+
const providers = new Set<string>();
|
|
30
|
+
|
|
31
|
+
for (const { ref, originalIndex } of fed) {
|
|
32
|
+
providers.add(ref.provider);
|
|
33
|
+
const key = modelRefLabel(ref);
|
|
34
|
+
const slots = clusters.get(key);
|
|
35
|
+
if (slots) slots.push(originalIndex);
|
|
36
|
+
else clusters.set(key, [originalIndex]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const distinctModels = clusters.size;
|
|
40
|
+
const distinctProviders = providers.size;
|
|
41
|
+
|
|
42
|
+
const countsLine = [
|
|
43
|
+
`Panel dependence: ${proposalCount} ${pluralize(proposalCount, "proposal")}`,
|
|
44
|
+
`from ${distinctModels} distinct ${pluralize(distinctModels, "model")}`,
|
|
45
|
+
`across ${distinctProviders} ${pluralize(distinctProviders, "provider")}.`,
|
|
46
|
+
].join(" ");
|
|
47
|
+
|
|
48
|
+
const clusterSentences = [...clusters.values()]
|
|
49
|
+
.filter((slots) => slots.length >= 2)
|
|
50
|
+
.sort((a, b) => a[0] - b[0])
|
|
51
|
+
.map((slots) => formatClusterSentence([...slots].sort((a, b) => a - b)));
|
|
52
|
+
|
|
53
|
+
const caveat = "Every proposer ran from the same prompt template, so even distinct models are not fully independent; treat agreement inside a same-model cluster as one vote, not as separate confirmation.";
|
|
54
|
+
|
|
55
|
+
return [countsLine, ...clusterSentences, caveat].join(" ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function duplicateModelSlotTotal(refs: readonly ModelRef[]): number {
|
|
59
|
+
const labelCounts = new Map<string, number>();
|
|
60
|
+
for (const ref of refs) {
|
|
61
|
+
const label = modelRefLabel(ref);
|
|
62
|
+
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (const count of labelCounts.values()) {
|
|
66
|
+
if (count >= 2) total += count;
|
|
67
|
+
}
|
|
68
|
+
return total;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Non-blocking warning for the pre-flight picker or replacement notify. */
|
|
72
|
+
export function proposerDiversityWarning(refs: readonly ModelRef[]): string | undefined {
|
|
73
|
+
const duplicateTotal = duplicateModelSlotTotal(refs);
|
|
74
|
+
if (duplicateTotal >= 2) {
|
|
75
|
+
return `${duplicateTotal} proposer slots share a model; agreement between them is not independent evidence.`;
|
|
76
|
+
}
|
|
77
|
+
if (refs.length >= 2 && new Set(refs.map((ref) => ref.provider)).size === 1) {
|
|
78
|
+
return `All ${refs.length} proposer slots share one provider; agreement between them is not independent evidence.`;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Slots in `refs` whose model matches `ref` (including `ref`'s own slot). */
|
|
84
|
+
export function duplicateModelSlotCount(refs: readonly ModelRef[], ref: ModelRef): number {
|
|
85
|
+
const target = modelRefLabel(ref);
|
|
86
|
+
return refs.reduce((count, candidate) => count + (modelRefLabel(candidate) === target ? 1 : 0), 0);
|
|
87
|
+
}
|
package/src/moa/reviewLoop.ts
CHANGED
|
@@ -73,7 +73,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
|
|
|
73
73
|
chatRounds++;
|
|
74
74
|
synthConversation.push(`The user reviewed your synthesized plan and gave this feedback:\n${feedback.trim()}\n\nRevise the plan accordingly and re-emit the complete final plan. Drop any ## Conflicts or ## Open Question sections.`);
|
|
75
75
|
rebuildSynthTask();
|
|
76
|
-
widget.switchToSynthesizing(getSynthesizer(), "synthesizing plan
|
|
76
|
+
widget.switchToSynthesizing(getSynthesizer(), "synthesizing plan", getSynthesizerThinking());
|
|
77
77
|
const synthRound = await runSynthWithRecovery(getSynthTask());
|
|
78
78
|
widget.stopWidget();
|
|
79
79
|
await warnIfMutated("MoA synthesis");
|
|
@@ -156,7 +156,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
|
|
|
156
156
|
Implement: handoff.model,
|
|
157
157
|
Verify: roles?.verifier,
|
|
158
158
|
});
|
|
159
|
-
widget.switchToImplementing(handoff.model, "implementing plan
|
|
159
|
+
widget.switchToImplementing(handoff.model, "implementing plan", handoff.thinking);
|
|
160
160
|
host.adoptProgressWidget(widget);
|
|
161
161
|
}
|
|
162
162
|
host.markImplementationPending(ctx);
|
package/src/moa/synthesis.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { buildContextRetryHeader, buildContextSubsectionsContract, missingContex
|
|
|
18
18
|
import { modelExtensionOptions, resolveContextWindow, resolveModelCost } from "./modelRuntime.ts";
|
|
19
19
|
import { runCriteriaGeneration } from "./verificationCriteria.ts";
|
|
20
20
|
import type { MoaRunContext, ReviewLoopOptions, SucceededProposal } from "./runContext.ts";
|
|
21
|
+
import { buildPanelDependenceNote } from "./panelDependence.ts";
|
|
21
22
|
import { buildVerdictContract, buildVerdictRetryHeader, missingVerdictSlots, parseProposerVerdicts, stripSynthSections } from "./verdicts.ts";
|
|
22
23
|
|
|
23
24
|
export interface SynthesisPhaseOptions {
|
|
@@ -40,7 +41,7 @@ export async function runSynthesisPhase(
|
|
|
40
41
|
): Promise<SynthesisPhaseResult> {
|
|
41
42
|
const { host, session, widget } = runContext;
|
|
42
43
|
const { ctx, prompt, proposers, succeeded, observe, agents, warnIfMutated } = options;
|
|
43
|
-
widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans
|
|
44
|
+
widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans", runContext.synthesizerThinking);
|
|
44
45
|
|
|
45
46
|
// Proposals are both inlined and staged on disk. Inlining is what
|
|
46
47
|
// guarantees the synthesizer actually weighs every proposal — behind a
|
|
@@ -71,7 +72,10 @@ export async function runSynthesisPhase(
|
|
|
71
72
|
// reason: a bridged synthesizer that loses the agent prompt never learns
|
|
72
73
|
// the ## Conflicts markup and silently resolves every disagreement solo.
|
|
73
74
|
const fedLabels = succeeded.map(({ originalIndex }) => proposerBlindedLabel(originalIndex));
|
|
74
|
-
|
|
75
|
+
// Correlated-judge guard: the note exposes dependence structure (counts and
|
|
76
|
+
// same-model clusters) without unblinding — only slot numbers and integers.
|
|
77
|
+
const dependenceNote = buildPanelDependenceNote(succeeded);
|
|
78
|
+
const baseSynthTask = `${SYNTHESIZER_TASK_PREAMBLE}\n\nOriginal user request:\n${prompt}\n\n---\n\n${dependenceNote}\n\nIndependent proposer plans:\n\n${proposals}\n\n---\n\n${buildVerdictContract(fedLabels)}\n\n---\n\n${buildContextSubsectionsContract()}\n\n---\n\n${buildConflictContract(fedLabels)}`;
|
|
75
79
|
const rebuildSynthTask = () => {
|
|
76
80
|
synthTask = [baseSynthTask, ...runContext.synthConversation.slice(-2)].join("\n\n---\n\n");
|
|
77
81
|
};
|
|
@@ -162,7 +166,7 @@ export async function runSynthesisPhase(
|
|
|
162
166
|
widget.stopWidget();
|
|
163
167
|
const action = await resolveSynthesizerFailure(getResultOutput(round.result));
|
|
164
168
|
if (action === "abandon") break;
|
|
165
|
-
widget.switchToSynthesizing(runContext.synthesizer, action === "switch" ? "retrying with a new model
|
|
169
|
+
widget.switchToSynthesizing(runContext.synthesizer, action === "switch" ? "retrying with a new model" : "retrying", runContext.synthesizerThinking);
|
|
166
170
|
round = await runSynthesizerRound(task);
|
|
167
171
|
}
|
|
168
172
|
return round;
|
|
@@ -237,7 +241,7 @@ export async function runSynthesisPhase(
|
|
|
237
241
|
}
|
|
238
242
|
runContext.synthConversation.push(`Synthesizer asked: ${question}\nUser answered: ${answer.trim()}\n\nNow produce the full plan.`);
|
|
239
243
|
rebuildSynthTask();
|
|
240
|
-
widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans
|
|
244
|
+
widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans", runContext.synthesizerThinking);
|
|
241
245
|
continue;
|
|
242
246
|
}
|
|
243
247
|
|
|
@@ -251,7 +255,7 @@ export async function runSynthesisPhase(
|
|
|
251
255
|
const synthRetryCorr = buildRetryCorrection(SYNTHESIZER_RETRY_HEADER, synthOutput);
|
|
252
256
|
runContext.synthConversation.push(synthRetryCorr);
|
|
253
257
|
rebuildSynthTask();
|
|
254
|
-
widget.switchToSynthesizing(runContext.synthesizer, "retrying: no plan produced
|
|
258
|
+
widget.switchToSynthesizing(runContext.synthesizer, "retrying: no plan produced", runContext.synthesizerThinking);
|
|
255
259
|
continue;
|
|
256
260
|
}
|
|
257
261
|
|
|
@@ -268,7 +272,7 @@ export async function runSynthesisPhase(
|
|
|
268
272
|
const verdictRetryCorr = buildRetryCorrection(buildVerdictRetryHeader(missingVerdicts), synthOutput);
|
|
269
273
|
runContext.synthConversation.push(verdictRetryCorr);
|
|
270
274
|
rebuildSynthTask();
|
|
271
|
-
widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing proposer verdicts
|
|
275
|
+
widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing proposer verdicts", runContext.synthesizerThinking);
|
|
272
276
|
continue;
|
|
273
277
|
}
|
|
274
278
|
|
|
@@ -283,7 +287,7 @@ export async function runSynthesisPhase(
|
|
|
283
287
|
const contextRetryCorr = buildRetryCorrection(buildContextRetryHeader(missingContext), synthOutput);
|
|
284
288
|
runContext.synthConversation.push(contextRetryCorr);
|
|
285
289
|
rebuildSynthTask();
|
|
286
|
-
widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing context sections
|
|
290
|
+
widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing context sections", runContext.synthesizerThinking);
|
|
287
291
|
continue;
|
|
288
292
|
}
|
|
289
293
|
if (!questionMatch && missingContext.length === 0) contextSubsectionsSeen = true;
|
|
@@ -341,7 +345,7 @@ export async function runSynthesisPhase(
|
|
|
341
345
|
|
|
342
346
|
runContext.synthConversation.push(feedback);
|
|
343
347
|
rebuildSynthTask();
|
|
344
|
-
widget.switchToSynthesizing(runContext.synthesizer, "synthesizing plan
|
|
348
|
+
widget.switchToSynthesizing(runContext.synthesizer, "synthesizing plan", runContext.synthesizerThinking);
|
|
345
349
|
if (isLastRound) {
|
|
346
350
|
const finalRound = await runSynthWithRecovery(synthTask);
|
|
347
351
|
if (finalRound.cancelled) {
|
package/src/moa/verification.ts
CHANGED
|
@@ -11,11 +11,17 @@
|
|
|
11
11
|
* evidence — the implementer's own report is treated as an untrusted claim.
|
|
12
12
|
*
|
|
13
13
|
* A `complete` verdict with a clean script gate ends the phase. Anything less
|
|
14
|
-
* offers the user a bounded repair round (up to
|
|
15
|
-
*
|
|
16
|
-
* dialog and sent back to the same
|
|
17
|
-
* kickoff machinery. The result is then
|
|
18
|
-
* as-is" is always available.
|
|
14
|
+
* offers the user a bounded repair round (up to the `maxVerificationRepairs`
|
|
15
|
+
* setting, default 2; 0 disables the offer): the verifier's gaps and failing
|
|
16
|
+
* checks are shown inline in the repair/accept dialog and sent back to the same
|
|
17
|
+
* in-session implementer via the existing kickoff machinery. The result is then
|
|
18
|
+
* verified again. "Accept implementation as-is" is always available.
|
|
19
|
+
*
|
|
20
|
+
* Once that budget is spent (or when it is zero, or no UI can offer the choice),
|
|
21
|
+
* the phase writes a `<slug>__verification-handoff.md` next to the plan holding
|
|
22
|
+
* every criterion's status plus the whole frozen checklist, and names that file
|
|
23
|
+
* in the exhaustion notification so the fix/verification loop can be continued
|
|
24
|
+
* by hand.
|
|
19
25
|
*/
|
|
20
26
|
|
|
21
27
|
import { CONFIG_DIR_NAME, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -42,9 +48,6 @@ import { parseVerificationCriteria, sectionBullets, type VerificationCriterion }
|
|
|
42
48
|
import type { MoaRunHost } from "./runContext.ts";
|
|
43
49
|
import { captureImplementationDiff, discoverVerifyScripts, runVerifyScript, type VerifyResult } from "./verifyGate.ts";
|
|
44
50
|
|
|
45
|
-
/** How many verifier-driven repair rounds a single implementation may consume. */
|
|
46
|
-
export const MAX_VERIFICATION_REPAIRS = 2;
|
|
47
|
-
|
|
48
51
|
export type VerificationVerdictValue = "complete" | "partial" | "incomplete" | "cannot-verify";
|
|
49
52
|
|
|
50
53
|
export interface CriterionVerdict {
|
|
@@ -349,6 +352,58 @@ export function formatVerificationReport(parsed: VerificationVerdict, failedScri
|
|
|
349
352
|
return sections.join("\n\n");
|
|
350
353
|
}
|
|
351
354
|
|
|
355
|
+
/** Markdown handoff written when automatic verifier repairs are exhausted or disabled. */
|
|
356
|
+
export function buildVerificationExhaustionHandoff(input: {
|
|
357
|
+
repairsUsed: number;
|
|
358
|
+
maxRepairs: number;
|
|
359
|
+
parsed: VerificationVerdict;
|
|
360
|
+
criteria: VerificationCriterion[];
|
|
361
|
+
failedScripts: VerifyResult[];
|
|
362
|
+
planFilePath: string;
|
|
363
|
+
verificationReportPath?: string;
|
|
364
|
+
/** Frozen checklist on disk, referenced alongside the inlined criteria. */
|
|
365
|
+
criteriaFilePath?: string;
|
|
366
|
+
}): string {
|
|
367
|
+
const { repairsUsed, maxRepairs, parsed, criteria, failedScripts, planFilePath, verificationReportPath, criteriaFilePath } = input;
|
|
368
|
+
const sections: string[] = [
|
|
369
|
+
"# Verification repair handoff",
|
|
370
|
+
"",
|
|
371
|
+
maxRepairs === 0
|
|
372
|
+
? "Automatic verifier repair rounds are disabled in settings (`maxVerificationRepairs: 0`)."
|
|
373
|
+
: `Verifier repair rounds are exhausted (${repairsUsed} of ${maxRepairs} used).`,
|
|
374
|
+
"",
|
|
375
|
+
"Use this file to continue the fix/verification loop manually: send the criteria status and gaps below to the implementer, apply fixes, then re-run verification (for example via `/mf-plan-implement` or by approving a follow-up turn).",
|
|
376
|
+
"",
|
|
377
|
+
`Approved plan: ${planFilePath}`,
|
|
378
|
+
];
|
|
379
|
+
if (verificationReportPath) sections.push(`Full verifier report: ${verificationReportPath}`);
|
|
380
|
+
if (criteriaFilePath) sections.push(`Frozen verification criteria: ${criteriaFilePath}`);
|
|
381
|
+
sections.push(
|
|
382
|
+
"",
|
|
383
|
+
"## Verification result",
|
|
384
|
+
`**Verdict:** ${parsed.verdict ?? "unknown"}`,
|
|
385
|
+
parsed.summary ? `**Summary:** ${parsed.summary}` : "**Summary:** (none)",
|
|
386
|
+
);
|
|
387
|
+
if (criteria.length > 0) {
|
|
388
|
+
const byId = new Map(parsed.criteria.map((criterion) => [criterion.id, criterion]));
|
|
389
|
+
const statusLines = criteria.map((criterion) => {
|
|
390
|
+
const verdict = byId.get(criterion.id);
|
|
391
|
+
if (!verdict) return `- **${criterion.id}:** not judged — ${criterion.text}`;
|
|
392
|
+
return `- **${criterion.id}:** ${verdict.status} — ${verdict.evidence || criterion.text}`;
|
|
393
|
+
});
|
|
394
|
+
sections.push("", "## Criteria status", ...statusLines, "", "## Full verification criteria", ...criteria.map((criterion) => `- **${criterion.id}:** ${criterion.text}`));
|
|
395
|
+
} else if (parsed.steps.length > 0) {
|
|
396
|
+
sections.push("", "## Step verdicts", ...parsed.steps.map((step) => `- ${step}`));
|
|
397
|
+
}
|
|
398
|
+
if (parsed.gaps.length > 0) {
|
|
399
|
+
sections.push("", "## Gaps", ...parsed.gaps.map((gap) => `- ${gap}`));
|
|
400
|
+
}
|
|
401
|
+
if (failedScripts.length > 0) {
|
|
402
|
+
sections.push("", "## Failing project checks", ...failedScripts.map((result) => `- \`${result.command}\` failed (exit ${result.exitCode})`));
|
|
403
|
+
}
|
|
404
|
+
return sections.join("\n");
|
|
405
|
+
}
|
|
406
|
+
|
|
352
407
|
function buildRepairNote(parsed: VerificationVerdict, failedScripts: VerifyResult[]): string {
|
|
353
408
|
const parts: string[] = [
|
|
354
409
|
"The verifier reviewed your implementation against the approved plan and found it did not fully land.",
|
|
@@ -434,7 +489,7 @@ export async function runVerificationDecision(deps: {
|
|
|
434
489
|
// Reactivate the Implement row for the repair round and leave the table
|
|
435
490
|
// mounted; the next settled turn re-enters this verification flow.
|
|
436
491
|
widget?.settleRoleRow("Verify", "done");
|
|
437
|
-
if (handoff.model) widget?.switchToImplementing(handoff.model, "addressing verifier findings
|
|
492
|
+
if (handoff.model) widget?.switchToImplementing(handoff.model, "addressing verifier findings", handoff.thinking);
|
|
438
493
|
host.markImplementationPending(ctx);
|
|
439
494
|
const repairNote = buildRepairNote(parsed, failedScripts);
|
|
440
495
|
await host.pi.sendUserMessage(
|
|
@@ -492,7 +547,7 @@ export async function runImplementationVerification(
|
|
|
492
547
|
widget?.setActivePhase("Verify");
|
|
493
548
|
// `activeVerifierThinking` is not declared yet; the initial project-checks
|
|
494
549
|
// activation carries the handoff's verifier level, which it is seeded from.
|
|
495
|
-
widget?.switchToVerifying(verifier, "running project checks
|
|
550
|
+
widget?.switchToVerifying(verifier, "running project checks", handoff.verifierThinking);
|
|
496
551
|
|
|
497
552
|
// Diff and script gate run in the parent, BEFORE the verifier's mutation
|
|
498
553
|
// tripwire is armed: `npm test` may legitimately write snapshots/coverage,
|
|
@@ -532,7 +587,7 @@ export async function runImplementationVerification(
|
|
|
532
587
|
|
|
533
588
|
const runVerifierPreflight = async (): Promise<VerifierRunOutcome> => {
|
|
534
589
|
const verifier = activeVerifier;
|
|
535
|
-
widget?.switchToVerifying(verifier, "checking verifier
|
|
590
|
+
widget?.switchToVerifying(verifier, "checking verifier", activeVerifierThinking);
|
|
536
591
|
const result = await runVerifier(
|
|
537
592
|
ctx.cwd, agents, "moa-verifier", "Reply with the single word ok", undefined, undefined, undefined,
|
|
538
593
|
modelRefLabel(verifier), activeVerifierThinking, {
|
|
@@ -548,7 +603,7 @@ export async function runImplementationVerification(
|
|
|
548
603
|
const tripwire = new MutationTripwire();
|
|
549
604
|
await tripwire.arm(ctx.cwd);
|
|
550
605
|
|
|
551
|
-
widget?.switchToVerifying(verifier, "verifying implementation
|
|
606
|
+
widget?.switchToVerifying(verifier, "verifying implementation", activeVerifierThinking);
|
|
552
607
|
widget?.updateRoleTranscript("Verify", []);
|
|
553
608
|
|
|
554
609
|
const session: CancelSession = { title: "MoA verification", run: undefined, overlayOpen: false };
|
|
@@ -756,15 +811,17 @@ export async function runImplementationVerification(
|
|
|
756
811
|
const failedScripts = scriptResults.filter((r) => r.status === "fail");
|
|
757
812
|
if (verificationPassed(parsed.verdict, scriptResults)) {
|
|
758
813
|
const checksNote = scriptResults.length > 0 ? " and all project checks passed" : "";
|
|
759
|
-
|
|
814
|
+
const message = criteria.length > 0
|
|
760
815
|
? `Verification passed — all ${criteria.length} criteria met${checksNote}.`
|
|
761
|
-
: `Verification passed — the implementation matches the approved plan${checksNote}
|
|
816
|
+
: `Verification passed — the implementation matches the approved plan${checksNote}.`;
|
|
817
|
+
ctx.ui.notify(ctx.ui.theme.bg("toolSuccessBg", ctx.ui.theme.fg("success", message)));
|
|
762
818
|
finish("done");
|
|
763
819
|
return;
|
|
764
820
|
}
|
|
765
821
|
|
|
766
822
|
const repairsUsed = activeHandoff.verificationRepairs ?? 0;
|
|
767
|
-
|
|
823
|
+
const maxRepairs = config.maxVerificationRepairs;
|
|
824
|
+
if (ctx.hasUI && maxRepairs > 0 && repairsUsed < maxRepairs) {
|
|
768
825
|
await runVerificationDecision({
|
|
769
826
|
ctx,
|
|
770
827
|
host,
|
|
@@ -785,7 +842,47 @@ export async function runImplementationVerification(
|
|
|
785
842
|
|
|
786
843
|
const summary = buildVerificationSummary(parsed, scriptResults).join("\n");
|
|
787
844
|
const report = formatVerificationReport(parsed, failedScripts);
|
|
788
|
-
|
|
789
|
-
|
|
845
|
+
let handoffPath: string | undefined;
|
|
846
|
+
// The run's own slug is authoritative, but a resumed implementation may have
|
|
847
|
+
// verified without one (e.g. /mf-plan-implement after a restart); the handoff
|
|
848
|
+
// carries the slug its plan was saved under, so fall back to it.
|
|
849
|
+
const repoPlanSlug = host.getPlanRepoSlug() ?? activeHandoff.repoPlanSlug;
|
|
850
|
+
if (repoPlanSlug) {
|
|
851
|
+
try {
|
|
852
|
+
saveRepoPlanFile(
|
|
853
|
+
buildVerificationExhaustionHandoff({
|
|
854
|
+
repairsUsed,
|
|
855
|
+
maxRepairs,
|
|
856
|
+
parsed,
|
|
857
|
+
criteria,
|
|
858
|
+
failedScripts,
|
|
859
|
+
planFilePath: activeHandoff.planFilePath,
|
|
860
|
+
verificationReportPath,
|
|
861
|
+
// Only point at the checklist file when one is actually on disk;
|
|
862
|
+
// criteria can come from the handoff alone.
|
|
863
|
+
criteriaFilePath: readRepoPlanFile(ctx.cwd, repoPlanSlug, "criteria")
|
|
864
|
+
? `${CONFIG_DIR_NAME}/mf-plan/${repoPlanSlug}__criteria.md`
|
|
865
|
+
: undefined,
|
|
866
|
+
}),
|
|
867
|
+
ctx.cwd,
|
|
868
|
+
repoPlanSlug,
|
|
869
|
+
"verification-handoff",
|
|
870
|
+
);
|
|
871
|
+
handoffPath = `${CONFIG_DIR_NAME}/mf-plan/${repoPlanSlug}__verification-handoff.md`;
|
|
872
|
+
} catch {
|
|
873
|
+
// Persisting the handoff is best-effort; the exhaustion notification still fires.
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// The notification is the only place the handoff is announced, so say plainly
|
|
877
|
+
// when it could not be written rather than silently dropping the location.
|
|
878
|
+
const handoffNote = handoffPath
|
|
879
|
+
? `\n\nContinue fix/verification using the handoff file:\n${handoffPath}`
|
|
880
|
+
: "\n\nThe verification handoff file could not be saved — the findings above are the only record.";
|
|
881
|
+
const exhaustedNote = maxRepairs === 0
|
|
882
|
+
? "\n\nAutomatic verifier repair rounds are disabled in settings."
|
|
883
|
+
: repairsUsed >= maxRepairs
|
|
884
|
+
? "\n\nRepair rounds are exhausted."
|
|
885
|
+
: "";
|
|
886
|
+
ctx.ui.notify(`${summary}\n\n${report}${exhaustedNote}${handoffNote}`, "warning");
|
|
790
887
|
finish("done");
|
|
791
888
|
}
|
|
@@ -84,7 +84,7 @@ export async function runCriteriaGeneration(input: {
|
|
|
84
84
|
observe?: ObserveSession;
|
|
85
85
|
}): Promise<{ criteria: VerificationCriterion[]; markdown: string } | undefined> {
|
|
86
86
|
const { ctx, agents, synthesizer, thinking, plan, session, widget } = input;
|
|
87
|
-
widget?.switchToSynthesizing(synthesizer, "writing verification criteria
|
|
87
|
+
widget?.switchToSynthesizing(synthesizer, "writing verification criteria", thinking);
|
|
88
88
|
const run = new CancelRun();
|
|
89
89
|
const slot = run.add(modelRefLabel(synthesizer));
|
|
90
90
|
if (session) {
|