@underactive/pi-topping-moa-fusion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/agents/mf-plan.md +43 -0
- package/agents/moa-debater.md +37 -0
- package/agents/moa-explore.md +56 -0
- package/agents/moa-opinion.md +29 -0
- package/agents/moa-proposer.md +49 -0
- package/agents/moa-synthesizer.md +124 -0
- package/agents/moa-verifier.md +67 -0
- package/index.ts +3 -0
- package/package.json +61 -0
- package/src/activityMeter.ts +193 -0
- package/src/agents/authoritative.ts +91 -0
- package/src/agents/defaults.ts +123 -0
- package/src/agents/discovery.ts +119 -0
- package/src/config/modelCatalogue.ts +54 -0
- package/src/config/planName.ts +74 -0
- package/src/config/rosters.ts +118 -0
- package/src/config/settings.ts +161 -0
- package/src/debate/debateContract.ts +89 -0
- package/src/debate/debateFanout.ts +285 -0
- package/src/debate/debateFile.ts +38 -0
- package/src/debate/debateResults.ts +115 -0
- package/src/debate/debateRounds.ts +61 -0
- package/src/debate/runDebate.ts +143 -0
- package/src/index.ts +283 -0
- package/src/moa/conflictContract.ts +49 -0
- package/src/moa/conflicts.ts +153 -0
- package/src/moa/contextContract.ts +52 -0
- package/src/moa/fanout.ts +152 -0
- package/src/moa/fanoutWiring.ts +88 -0
- package/src/moa/implementationRetry.ts +292 -0
- package/src/moa/modelRuntime.ts +87 -0
- package/src/moa/orchestration.ts +105 -0
- package/src/moa/planInfo.ts +57 -0
- package/src/moa/planlessRetry.ts +72 -0
- package/src/moa/reviewLoop.ts +170 -0
- package/src/moa/runContext.ts +118 -0
- package/src/moa/synthesis.ts +420 -0
- package/src/moa/verdicts.ts +81 -0
- package/src/moa/verification.ts +791 -0
- package/src/moa/verificationCriteria.ts +127 -0
- package/src/moa/verifyGate.ts +137 -0
- package/src/opinion/opinionContract.ts +21 -0
- package/src/opinion/opinionFanout.ts +135 -0
- package/src/opinion/opinionFile.ts +38 -0
- package/src/opinion/opinionResults.ts +73 -0
- package/src/opinion/runOpinion.ts +156 -0
- package/src/planning/askUserQuestion.ts +83 -0
- package/src/planning/instructions.ts +146 -0
- package/src/planning/modeState.ts +61 -0
- package/src/planning/planFile.ts +273 -0
- package/src/planning/planMode.ts +673 -0
- package/src/planning/tools/enterPlanMode.ts +165 -0
- package/src/planning/tools/exitPlanMode.ts +159 -0
- package/src/planning/tools/mfPlanSubagent.ts +311 -0
- package/src/planning/tools/shared.ts +19 -0
- package/src/planning/tools/writePlan.ts +33 -0
- package/src/runtime/activityTracking.ts +141 -0
- package/src/runtime/cancelRun.ts +134 -0
- package/src/runtime/mutationTripwire.ts +251 -0
- package/src/runtime/processPool.ts +55 -0
- package/src/runtime/results.ts +103 -0
- package/src/runtime/runner.ts +538 -0
- package/src/runtime/wire.ts +177 -0
- package/src/shared/functionKeys.ts +30 -0
- package/src/shared/modelRefs.ts +91 -0
- package/src/ui/agentStatus.ts +84 -0
- package/src/ui/agentTranscript.ts +112 -0
- package/src/ui/cancelOverlay.ts +191 -0
- package/src/ui/chrome.ts +151 -0
- package/src/ui/conflictOverlay.ts +363 -0
- package/src/ui/debateModelPicker.ts +273 -0
- package/src/ui/menu.ts +679 -0
- package/src/ui/moaModelPicker.ts +900 -0
- package/src/ui/moaProgressWidget.ts +910 -0
- package/src/ui/moaSetupOverlay.ts +368 -0
- package/src/ui/modelLabel.ts +61 -0
- package/src/ui/observeOverlay.ts +206 -0
- package/src/ui/opinionModelPicker.ts +246 -0
- package/src/ui/planReviewOverlay.ts +315 -0
- package/src/ui/promptEditor.ts +87 -0
- package/src/ui/rosterEditor.ts +310 -0
- package/src/ui/shimmer.ts +77 -0
- package/src/ui/toolActivity.ts +35 -0
- package/src/ui/twoPaneModelThinking.ts +272 -0
- package/src/ui/verificationFindingsOverlay.ts +137 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { AgentConfig } from "../agents/discovery.ts";
|
|
4
|
+
import { loadMoaConfig } from "../config/settings.ts";
|
|
5
|
+
import { writeProposalFiles } from "../planning/planFile.ts";
|
|
6
|
+
import { CancelRun } from "../runtime/cancelRun.ts";
|
|
7
|
+
import { getFinalOutput, getResultOutput, isFailedResult } from "../runtime/results.ts";
|
|
8
|
+
import { runSingleAgent } from "../runtime/runner.ts";
|
|
9
|
+
import { modelRefLabel, proposerBlindedLabel, TRIGGER_TURN, type ModelRef } from "../shared/modelRefs.ts";
|
|
10
|
+
import { activityLoopCount } from "../ui/agentStatus.ts";
|
|
11
|
+
import { showConflictReview } from "../ui/conflictOverlay.ts";
|
|
12
|
+
import { showImplementingModelPicker } from "../ui/moaModelPicker.ts";
|
|
13
|
+
import type { ObserveSession } from "../ui/observeOverlay.ts";
|
|
14
|
+
import { buildConflictContract } from "./conflictContract.ts";
|
|
15
|
+
import { parseConflicts } from "./conflicts.ts";
|
|
16
|
+
import { buildRetryCorrection, looksLikePlan, SYNTHESIZER_RETRY_HEADER, SYNTHESIZER_TASK_PREAMBLE } from "./planlessRetry.ts";
|
|
17
|
+
import { buildContextRetryHeader, buildContextSubsectionsContract, missingContextSubsections } from "./contextContract.ts";
|
|
18
|
+
import { modelExtensionOptions, resolveContextWindow, resolveModelCost } from "./modelRuntime.ts";
|
|
19
|
+
import { runCriteriaGeneration } from "./verificationCriteria.ts";
|
|
20
|
+
import type { MoaRunContext, ReviewLoopOptions, SucceededProposal } from "./runContext.ts";
|
|
21
|
+
import { buildVerdictContract, buildVerdictRetryHeader, missingVerdictSlots, parseProposerVerdicts, stripSynthSections } from "./verdicts.ts";
|
|
22
|
+
|
|
23
|
+
export interface SynthesisPhaseOptions {
|
|
24
|
+
ctx: ExtensionContext;
|
|
25
|
+
prompt: string;
|
|
26
|
+
proposers: ModelRef[];
|
|
27
|
+
succeeded: SucceededProposal[];
|
|
28
|
+
observe: ObserveSession;
|
|
29
|
+
agents: AgentConfig[];
|
|
30
|
+
warnIfMutated(phase: string): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SynthesisPhaseResult =
|
|
34
|
+
| { status: "done" | "cancelled" }
|
|
35
|
+
| { status: "review"; options: ReviewLoopOptions };
|
|
36
|
+
|
|
37
|
+
export async function runSynthesisPhase(
|
|
38
|
+
runContext: MoaRunContext,
|
|
39
|
+
options: SynthesisPhaseOptions,
|
|
40
|
+
): Promise<SynthesisPhaseResult> {
|
|
41
|
+
const { host, session, widget } = runContext;
|
|
42
|
+
const { ctx, prompt, proposers, succeeded, observe, agents, warnIfMutated } = options;
|
|
43
|
+
widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans…", runContext.synthesizerThinking);
|
|
44
|
+
|
|
45
|
+
// Proposals are both inlined and staged on disk. Inlining is what
|
|
46
|
+
// guarantees the synthesizer actually weighs every proposal — behind a
|
|
47
|
+
// file reference alone it could silently read three of five and still
|
|
48
|
+
// emit a confident plan. The staged copies exist for the case inlining
|
|
49
|
+
// cannot survive: compaction reserves a flat 16k tokens regardless of
|
|
50
|
+
// context window, so a small-window synthesizer can cross the threshold
|
|
51
|
+
// on its first turn and reconcile an LLM summary of the proposals
|
|
52
|
+
// instead of the proposals themselves.
|
|
53
|
+
runContext.proposalFiles = writeProposalFiles(
|
|
54
|
+
succeeded.map(({ plan, originalIndex }) => ({ label: proposerBlindedLabel(originalIndex), plan })),
|
|
55
|
+
);
|
|
56
|
+
const stagedFiles = runContext.proposalFiles?.files;
|
|
57
|
+
|
|
58
|
+
const proposals = succeeded
|
|
59
|
+
.map(({ plan, originalIndex }, i) => {
|
|
60
|
+
const stagedPath = stagedFiles?.[i]?.path;
|
|
61
|
+
const pointer = stagedPath ? `\nVerbatim copy on disk: ${stagedPath}\n` : "";
|
|
62
|
+
return `### Proposal from ${proposerBlindedLabel(originalIndex)}\n${pointer}\n${plan}`;
|
|
63
|
+
})
|
|
64
|
+
.join("\n\n---\n\n");
|
|
65
|
+
|
|
66
|
+
// The preamble carries the read-only planning contract inside the task
|
|
67
|
+
// itself: bridged providers can replace the moa-synthesizer system prompt
|
|
68
|
+
// with their own harness, and without it they read an "implement X"
|
|
69
|
+
// user request as marching orders and stall on their read-only tools.
|
|
70
|
+
// The verdict and conflict contracts ride the task text for the same
|
|
71
|
+
// reason: a bridged synthesizer that loses the agent prompt never learns
|
|
72
|
+
// the ## Conflicts markup and silently resolves every disagreement solo.
|
|
73
|
+
const fedLabels = succeeded.map(({ originalIndex }) => proposerBlindedLabel(originalIndex));
|
|
74
|
+
const baseSynthTask = `${SYNTHESIZER_TASK_PREAMBLE}\n\nOriginal user request:\n${prompt}\n\n---\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
|
+
const rebuildSynthTask = () => {
|
|
76
|
+
synthTask = [baseSynthTask, ...runContext.synthConversation.slice(-2)].join("\n\n---\n\n");
|
|
77
|
+
};
|
|
78
|
+
let synthTask = baseSynthTask;
|
|
79
|
+
let synthOutput = "";
|
|
80
|
+
// Initial pass + rounds for Open-Question clarifications and/or conflict-review re-synths.
|
|
81
|
+
const MAX_SYNTH_ROUNDS = 5;
|
|
82
|
+
let synthPlanlessRetried = false;
|
|
83
|
+
let verdictRetried = false;
|
|
84
|
+
let contextRetried = false;
|
|
85
|
+
// Whether any accepted round's output carried all three Context subsections
|
|
86
|
+
// — conflict/chat revisions may legitimately drop them once seen, so the
|
|
87
|
+
// final warning keys on this rather than on the last output alone.
|
|
88
|
+
let contextSubsectionsSeen = false;
|
|
89
|
+
const runSynthesizerRound = async (task: string) => {
|
|
90
|
+
const synthRun = new CancelRun();
|
|
91
|
+
const label = modelRefLabel(runContext.synthesizer);
|
|
92
|
+
session.title = "MoA synthesis";
|
|
93
|
+
session.getExtras = () => {
|
|
94
|
+
const s = widget.getRoleStatus("Synthesize");
|
|
95
|
+
return {
|
|
96
|
+
contextTokens: s.contextTokens,
|
|
97
|
+
contextWindow: s.ref ? resolveContextWindow(ctx, s.ref) : undefined,
|
|
98
|
+
activity: s.activity,
|
|
99
|
+
loopCount: activityLoopCount(s.activity, s.activityHistory),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
observe.phase = "synthesizing";
|
|
103
|
+
observe.title = "MoA synthesis";
|
|
104
|
+
observe.synthesizer = {
|
|
105
|
+
label: runContext.synthesizer.id,
|
|
106
|
+
model: runContext.synthesizer.id,
|
|
107
|
+
task,
|
|
108
|
+
messages: [],
|
|
109
|
+
state: "working",
|
|
110
|
+
};
|
|
111
|
+
const synthSlot = synthRun.add(label);
|
|
112
|
+
widget.updateRoleTranscript("Synthesize", []);
|
|
113
|
+
session.run = synthRun;
|
|
114
|
+
try {
|
|
115
|
+
const result = await runSingleAgent(
|
|
116
|
+
ctx.cwd, agents, "moa-synthesizer", task, undefined, synthSlot.signal, undefined,
|
|
117
|
+
label, runContext.synthesizerThinking, {
|
|
118
|
+
...modelExtensionOptions(ctx, runContext.synthesizer),
|
|
119
|
+
resolveOnAbort: true,
|
|
120
|
+
onProgress: (r) => {
|
|
121
|
+
widget.updateRoleUsage(
|
|
122
|
+
"Synthesize",
|
|
123
|
+
r.usage.contextTokens,
|
|
124
|
+
r.usage.turns,
|
|
125
|
+
r.usage.toolCalls,
|
|
126
|
+
resolveModelCost(ctx, runContext.synthesizer, r.usage),
|
|
127
|
+
);
|
|
128
|
+
if (r.activity) widget.updateRoleActivity("Synthesize", r.activity);
|
|
129
|
+
if (r.outputActivity) widget.updateRoleOutput("Synthesize", r.outputActivity.tokens, r.outputActivity.revision);
|
|
130
|
+
widget.updateRoleTranscript("Synthesize", r.messages, r.partialAssistant);
|
|
131
|
+
if (observe.synthesizer) {
|
|
132
|
+
observe.synthesizer.messages = r.messages;
|
|
133
|
+
observe.synthesizer.partial = r.partialAssistant;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
if (result.cancelled || synthRun.cancelAllRequested) {
|
|
139
|
+
if (observe.synthesizer) observe.synthesizer.state = "cancelled";
|
|
140
|
+
return { result, cancelled: true };
|
|
141
|
+
}
|
|
142
|
+
if (isFailedResult(result)) {
|
|
143
|
+
if (observe.synthesizer) observe.synthesizer.state = "error";
|
|
144
|
+
return { result, failed: true };
|
|
145
|
+
}
|
|
146
|
+
widget.updateRoleTranscript("Synthesize", result.messages);
|
|
147
|
+
if (observe.synthesizer) {
|
|
148
|
+
observe.synthesizer.messages = result.messages;
|
|
149
|
+
observe.synthesizer.partial = undefined;
|
|
150
|
+
observe.synthesizer.state = "done";
|
|
151
|
+
}
|
|
152
|
+
return { result, cancelled: false, failed: false };
|
|
153
|
+
} finally {
|
|
154
|
+
session.run = undefined;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const runSynthWithRecovery = async (task: string) => {
|
|
159
|
+
let round = await runSynthesizerRound(task);
|
|
160
|
+
while (round.failed && !round.cancelled) {
|
|
161
|
+
session.closeOverlay?.();
|
|
162
|
+
widget.stopWidget();
|
|
163
|
+
const action = await resolveSynthesizerFailure(getResultOutput(round.result));
|
|
164
|
+
if (action === "abandon") break;
|
|
165
|
+
widget.switchToSynthesizing(runContext.synthesizer, action === "switch" ? "retrying with a new model…" : "retrying…", runContext.synthesizerThinking);
|
|
166
|
+
round = await runSynthesizerRound(task);
|
|
167
|
+
}
|
|
168
|
+
return round;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// A synthesizer crash (500 with no body, a 401 credits error, etc.) must
|
|
172
|
+
// never silently discard the fan-out: the proposer plans already cost
|
|
173
|
+
// real work, so offer to retry, switch models, or — as a last resort —
|
|
174
|
+
// hand the plans to the session model instead of re-running from the
|
|
175
|
+
// bare prompt. Switching reassigns `synthesizer` itself, so every
|
|
176
|
+
// closure above that reads it (runSynthesizerRound, widget calls,
|
|
177
|
+
// activeRunMoaInfo below) picks up the new model automatically.
|
|
178
|
+
const resolveSynthesizerFailure = async (errorMessage: string): Promise<"retry" | "switch" | "abandon"> => {
|
|
179
|
+
while (true) {
|
|
180
|
+
if (!ctx.hasUI) return "abandon";
|
|
181
|
+
ctx.ui.notify(`Synthesizer failed: ${errorMessage}`, "error");
|
|
182
|
+
const label = modelRefLabel(runContext.synthesizer);
|
|
183
|
+
const choice = await ctx.ui.select(
|
|
184
|
+
"Synthesizer failed — proposer plans are still available. What next?",
|
|
185
|
+
[`Retry ${label}`, "Choose a different model", "Give up on MoA synthesis"],
|
|
186
|
+
);
|
|
187
|
+
if (choice === `Retry ${label}`) return "retry";
|
|
188
|
+
if (choice === "Choose a different model") {
|
|
189
|
+
const picked = await showImplementingModelPicker(ctx, host.currentThinkingLevel(), "Synthesizer failed — choose a different model");
|
|
190
|
+
if (!picked) continue;
|
|
191
|
+
runContext.synthesizer = picked.ref;
|
|
192
|
+
runContext.synthesizerThinking = picked.thinking;
|
|
193
|
+
widget.setPhaseModels({
|
|
194
|
+
Plan: proposers,
|
|
195
|
+
Synthesize: picked.ref,
|
|
196
|
+
Implement: runContext.roles?.implementer,
|
|
197
|
+
Verify: runContext.roles?.verifier,
|
|
198
|
+
});
|
|
199
|
+
return "switch";
|
|
200
|
+
}
|
|
201
|
+
return "abandon";
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
for (let round = 0; round < MAX_SYNTH_ROUNDS; round++) {
|
|
206
|
+
const synthRound = await runSynthWithRecovery(synthTask);
|
|
207
|
+
const synthResult = synthRound.result;
|
|
208
|
+
if (synthRound.cancelled) {
|
|
209
|
+
widget.stopWidget();
|
|
210
|
+
await warnIfMutated("MoA synthesis");
|
|
211
|
+
ctx.ui.notify("Synthesis cancelled.");
|
|
212
|
+
return { status: "cancelled" };
|
|
213
|
+
}
|
|
214
|
+
if (synthRound.failed) {
|
|
215
|
+
widget.stopWidget();
|
|
216
|
+
await warnIfMutated("MoA synthesis");
|
|
217
|
+
ctx.ui.notify("Synthesizer unavailable — handing the proposer plans to the session model.", "warning");
|
|
218
|
+
host.pi.sendUserMessage(
|
|
219
|
+
`The MoA synthesizer model failed and no other model was picked. Acting as the synthesizer yourself, review the independent proposer plans below and produce one final plan for the user's original request.\n\n${synthTask}`,
|
|
220
|
+
TRIGGER_TURN,
|
|
221
|
+
);
|
|
222
|
+
return { status: "done" };
|
|
223
|
+
}
|
|
224
|
+
synthOutput = getFinalOutput(synthResult.messages);
|
|
225
|
+
const questionMatch = synthOutput.match(/^##\s*Open Question\s*\n([\s\S]*)$/m);
|
|
226
|
+
|
|
227
|
+
if (questionMatch && round < MAX_SYNTH_ROUNDS - 1) {
|
|
228
|
+
widget.stopWidget();
|
|
229
|
+
// A lingering cancel overlay must not fight the input prompt for focus.
|
|
230
|
+
session.closeOverlay?.();
|
|
231
|
+
const rawQuestion = questionMatch[1].trim();
|
|
232
|
+
const question = rawQuestion.split("\n##")[0].slice(0, 2000).trim();
|
|
233
|
+
const answer = await ctx.ui.editor(question);
|
|
234
|
+
if (!answer || !answer.trim()) {
|
|
235
|
+
// User declined to answer — proceed with whatever the synthesizer has so far.
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
runContext.synthConversation.push(`Synthesizer asked: ${question}\nUser answered: ${answer.trim()}\n\nNow produce the full plan.`);
|
|
239
|
+
rebuildSynthTask();
|
|
240
|
+
widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans…", runContext.synthesizerThinking);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Like proposers, a synthesizer can exit cleanly without delivering a
|
|
245
|
+
// plan — e.g. a bridged agent that lost the moa-synthesizer prompt
|
|
246
|
+
// refuses with "I'm blocked: read-only tools" instead of synthesizing.
|
|
247
|
+
// Writing that refusal to the plan file would present it to the user
|
|
248
|
+
// as the proposed plan, so re-run once with a corrective instruction.
|
|
249
|
+
if (!questionMatch && !looksLikePlan(synthOutput) && !synthPlanlessRetried && round < MAX_SYNTH_ROUNDS - 1) {
|
|
250
|
+
synthPlanlessRetried = true;
|
|
251
|
+
const synthRetryCorr = buildRetryCorrection(SYNTHESIZER_RETRY_HEADER, synthOutput);
|
|
252
|
+
runContext.synthConversation.push(synthRetryCorr);
|
|
253
|
+
rebuildSynthTask();
|
|
254
|
+
widget.switchToSynthesizing(runContext.synthesizer, "retrying: no plan produced…", runContext.synthesizerThinking);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Proof of judging: a plan without a verdict for every fed proposal
|
|
259
|
+
// slot is indistinguishable from a solo plan that ignored the fan-out,
|
|
260
|
+
// so reject it once with the missing slots named. Gate on the latest
|
|
261
|
+
// verdicts seen across rounds — conflict/chat revisions may omit the
|
|
262
|
+
// section once a complete set was already captured.
|
|
263
|
+
const verdictCheck = parseProposerVerdicts(synthOutput);
|
|
264
|
+
if (verdictCheck.verdictsMarkdown) runContext.latestVerdicts = verdictCheck.verdictsMarkdown;
|
|
265
|
+
const missingVerdicts = missingVerdictSlots(runContext.latestVerdicts, fedLabels);
|
|
266
|
+
if (!questionMatch && missingVerdicts.length > 0 && !verdictRetried && round < MAX_SYNTH_ROUNDS - 1) {
|
|
267
|
+
verdictRetried = true;
|
|
268
|
+
const verdictRetryCorr = buildRetryCorrection(buildVerdictRetryHeader(missingVerdicts), synthOutput);
|
|
269
|
+
runContext.synthConversation.push(verdictRetryCorr);
|
|
270
|
+
rebuildSynthTask();
|
|
271
|
+
widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing proposer verdicts…", runContext.synthesizerThinking);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Auditable reasoning: a plan whose Context lacks the three mandated
|
|
276
|
+
// subsections hides how the proposals were weighed and recombined, so
|
|
277
|
+
// reject it once with the missing headings named. Sits after the
|
|
278
|
+
// verdict gate (same structural-completeness class) and before conflict
|
|
279
|
+
// parsing so a retry never re-opens already-parsed conflicts.
|
|
280
|
+
const missingContext = missingContextSubsections(synthOutput);
|
|
281
|
+
if (!questionMatch && missingContext.length > 0 && !contextRetried && round < MAX_SYNTH_ROUNDS - 1) {
|
|
282
|
+
contextRetried = true;
|
|
283
|
+
const contextRetryCorr = buildRetryCorrection(buildContextRetryHeader(missingContext), synthOutput);
|
|
284
|
+
runContext.synthConversation.push(contextRetryCorr);
|
|
285
|
+
rebuildSynthTask();
|
|
286
|
+
widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing context sections…", runContext.synthesizerThinking);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!questionMatch && missingContext.length === 0) contextSubsectionsSeen = true;
|
|
290
|
+
|
|
291
|
+
const { conflicts, remainingPlan } = parseConflicts(synthOutput);
|
|
292
|
+
|
|
293
|
+
// Diagnostic only: on the initial synthesis pass, 2+ proposers succeeded
|
|
294
|
+
// but the synthesizer emitted no ## Conflicts at all. This is expected
|
|
295
|
+
// when proposers genuinely agreed on every decision point, but it's also
|
|
296
|
+
// the exact symptom of a stale/non-conflict-aware synthesizer prompt, so
|
|
297
|
+
// surface a low-severity hint rather than fail silently either way.
|
|
298
|
+
if (round === 0 && conflicts.length === 0 && succeeded.length >= 2) {
|
|
299
|
+
ctx.ui.notify(
|
|
300
|
+
"MoA synthesizer returned no conflicts — if proposers disagreed, verify the bundled synthesizer prompt is in effect.",
|
|
301
|
+
"warning",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (conflicts.length > 0) {
|
|
306
|
+
const isLastRound = round === MAX_SYNTH_ROUNDS - 1;
|
|
307
|
+
if ((round < MAX_SYNTH_ROUNDS - 1 || (isLastRound && ctx.mode === "tui")) && !loadMoaConfig().autoResolveConflicts) {
|
|
308
|
+
widget.stopWidget();
|
|
309
|
+
// A lingering cancel overlay must not fight the conflict overlay for focus.
|
|
310
|
+
session.closeOverlay?.();
|
|
311
|
+
const review = await showConflictReview(ctx, conflicts);
|
|
312
|
+
|
|
313
|
+
if (review.cancelled) {
|
|
314
|
+
await warnIfMutated("MoA synthesis");
|
|
315
|
+
ctx.ui.notify("MoA synthesis cancelled.");
|
|
316
|
+
return { status: "cancelled" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const feedbackParts: string[] = ["User reviewed your conflict recommendations:"];
|
|
320
|
+
for (const conflict of conflicts) {
|
|
321
|
+
const answer = review.answers.get(conflict.id);
|
|
322
|
+
feedbackParts.push(`\n### Conflict: ${conflict.label}`);
|
|
323
|
+
if (!answer) continue;
|
|
324
|
+
if (answer.kind === "chat") {
|
|
325
|
+
feedbackParts.push(`User wants to chat: ${answer.chatText ?? ""}`);
|
|
326
|
+
} else {
|
|
327
|
+
const opt = conflict.options.find((o) => o.value === answer.optionValue);
|
|
328
|
+
if (answer.kind === "recommended") {
|
|
329
|
+
feedbackParts.push(`User confirmed your recommendation: ${opt?.label ?? answer.optionValue}`);
|
|
330
|
+
} else {
|
|
331
|
+
feedbackParts.push(
|
|
332
|
+
`User chose the alternative from ${opt?.proposerLabel ?? "a proposer"}: ${opt?.label ?? answer.optionValue}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
feedbackParts.push(
|
|
338
|
+
"\nIncorporate these resolutions and re-emit the final plan. Drop the ## Conflicts section. Keep the answer complete.",
|
|
339
|
+
);
|
|
340
|
+
const feedback = feedbackParts.join("\n");
|
|
341
|
+
|
|
342
|
+
runContext.synthConversation.push(feedback);
|
|
343
|
+
rebuildSynthTask();
|
|
344
|
+
widget.switchToSynthesizing(runContext.synthesizer, "synthesizing plan…", runContext.synthesizerThinking);
|
|
345
|
+
if (isLastRound) {
|
|
346
|
+
const finalRound = await runSynthWithRecovery(synthTask);
|
|
347
|
+
if (finalRound.cancelled) {
|
|
348
|
+
widget.stopWidget();
|
|
349
|
+
await warnIfMutated("MoA synthesis");
|
|
350
|
+
ctx.ui.notify("Synthesis cancelled.");
|
|
351
|
+
return { status: "cancelled" };
|
|
352
|
+
}
|
|
353
|
+
if (!finalRound.failed) {
|
|
354
|
+
synthOutput = getFinalOutput(finalRound.result.messages);
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
continue;
|
|
359
|
+
} else {
|
|
360
|
+
ctx.ui.notify(`Auto-accepted ${conflicts.length} recommended conflict choice${conflicts.length === 1 ? "" : "s"}.`);
|
|
361
|
+
synthOutput = remainingPlan;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
synthOutput = remainingPlan;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
widget.stopWidget();
|
|
371
|
+
|
|
372
|
+
await warnIfMutated("MoA synthesis");
|
|
373
|
+
|
|
374
|
+
synthOutput = stripSynthSections(synthOutput);
|
|
375
|
+
if (runContext.latestVerdicts === null) {
|
|
376
|
+
ctx.ui.notify(
|
|
377
|
+
"MoA synthesizer produced no proposer verdicts even after a retry — there is no proof the proposals were evaluated.",
|
|
378
|
+
"warning",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (!contextSubsectionsSeen) {
|
|
382
|
+
ctx.ui.notify(
|
|
383
|
+
"MoA synthesizer omitted the required Context subsections (Evaluation dimensions, Proposer alignment, Synthesis decisions) even after a retry — the reconciliation reasoning is not auditable in this plan.",
|
|
384
|
+
"warning",
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
status: "review",
|
|
390
|
+
options: {
|
|
391
|
+
host,
|
|
392
|
+
ctx,
|
|
393
|
+
session,
|
|
394
|
+
widget,
|
|
395
|
+
proposers,
|
|
396
|
+
succeeded,
|
|
397
|
+
initialPlan: synthOutput,
|
|
398
|
+
synthConversation: runContext.synthConversation,
|
|
399
|
+
getSynthesizer: () => runContext.synthesizer,
|
|
400
|
+
getSynthesizerThinking: () => runContext.synthesizerThinking,
|
|
401
|
+
getLatestVerdicts: () => runContext.latestVerdicts,
|
|
402
|
+
setLatestVerdicts: (markdown) => { runContext.latestVerdicts = markdown; },
|
|
403
|
+
rebuildSynthTask,
|
|
404
|
+
getSynthTask: () => synthTask,
|
|
405
|
+
runSynthWithRecovery,
|
|
406
|
+
warnIfMutated,
|
|
407
|
+
roles: runContext.roles,
|
|
408
|
+
generateVerificationCriteria: (plan) => runCriteriaGeneration({
|
|
409
|
+
ctx,
|
|
410
|
+
agents,
|
|
411
|
+
synthesizer: runContext.synthesizer,
|
|
412
|
+
thinking: runContext.synthesizerThinking,
|
|
413
|
+
plan,
|
|
414
|
+
session,
|
|
415
|
+
widget,
|
|
416
|
+
observe,
|
|
417
|
+
}),
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof-of-judging contract for the MoA synthesizer.
|
|
3
|
+
*
|
|
4
|
+
* A synthesizer can emit a perfectly plausible plan without ever weighing the
|
|
5
|
+
* proposals it was handed — nothing in prose distinguishes "synthesized from
|
|
6
|
+
* five proposals" from "planned solo". The orchestrator knows exactly which
|
|
7
|
+
* blinded slots it fed in, so it demands a parseable `## Proposer Verdicts`
|
|
8
|
+
* section with one verdict per slot and rejects (one corrective retry) any
|
|
9
|
+
* first full plan that skips a slot. Like the rest of the synthesis
|
|
10
|
+
* discipline, the requirement rides the task text because provider bridges
|
|
11
|
+
* drop the agent system prompt (see planlessRetry.ts).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseConflicts } from "./conflicts.ts";
|
|
15
|
+
import { buildRetryCorrection } from "./planlessRetry.ts";
|
|
16
|
+
|
|
17
|
+
const VERDICTS_SECTION_RE = /^##\s*Proposer Verdicts\s*$/m;
|
|
18
|
+
const VERDICT_BULLET_RE = /^[-*]\s+\*\*(Proposer \d+):?\*\*:?\s*(.+)$/gm;
|
|
19
|
+
|
|
20
|
+
/** Task-text block demanding one verdict per fed proposal slot. */
|
|
21
|
+
export function buildVerdictContract(labels: string[]): string {
|
|
22
|
+
return [
|
|
23
|
+
"Judging proof requirement: every full plan you emit MUST include a `## Proposer Verdicts` section, placed after `## Risks` and before any `## Conflicts`, with exactly one bullet per proposal in this format:",
|
|
24
|
+
"",
|
|
25
|
+
"- **Proposer N:** adopted | partial | rejected — one line naming what you took or rejected from that proposal and why.",
|
|
26
|
+
"",
|
|
27
|
+
`Proposals requiring a verdict: ${labels.join(", ")}. A plan missing a verdict for any of these will be rejected and sent back to you.`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface VerdictParse {
|
|
32
|
+
/** The full section including its heading, or null when absent. */
|
|
33
|
+
verdictsMarkdown: string | null;
|
|
34
|
+
remainingPlan: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Extract and strip the `## Proposer Verdicts` section (bounded by the next H2). */
|
|
38
|
+
export function parseProposerVerdicts(output: string): VerdictParse {
|
|
39
|
+
const startMatch = VERDICTS_SECTION_RE.exec(output);
|
|
40
|
+
if (!startMatch) return { verdictsMarkdown: null, remainingPlan: output };
|
|
41
|
+
|
|
42
|
+
const sectionStart = startMatch.index;
|
|
43
|
+
const afterHeader = sectionStart + startMatch[0].length;
|
|
44
|
+
const rest = output.slice(afterHeader);
|
|
45
|
+
const nextMatch = /^##\s+/m.exec(rest);
|
|
46
|
+
const sectionEnd = nextMatch ? afterHeader + nextMatch.index : output.length;
|
|
47
|
+
|
|
48
|
+
const verdictsMarkdown = output.slice(sectionStart, sectionEnd).trim();
|
|
49
|
+
const remainingPlan = (output.slice(0, sectionStart) + output.slice(sectionEnd))
|
|
50
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
51
|
+
.trim();
|
|
52
|
+
return { verdictsMarkdown, remainingPlan };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function stripSynthSections(text: string): string {
|
|
56
|
+
const withoutOpenQuestion = text.replace(
|
|
57
|
+
/^##\s*Open Question\s*$[\s\S]*?(?=^##\s+|(?![\s\S]))/m,
|
|
58
|
+
"",
|
|
59
|
+
);
|
|
60
|
+
return parseProposerVerdicts(parseConflicts(withoutOpenQuestion).remainingPlan).remainingPlan
|
|
61
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
62
|
+
.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fed slot labels that have no verdict bullet with a non-empty justification. */
|
|
66
|
+
export function missingVerdictSlots(verdictsMarkdown: string | null, labels: string[]): string[] {
|
|
67
|
+
if (!verdictsMarkdown) return [...labels];
|
|
68
|
+
const covered = new Set<string>();
|
|
69
|
+
for (const match of verdictsMarkdown.matchAll(VERDICT_BULLET_RE)) {
|
|
70
|
+
if (match[2].trim()) covered.add(match[1]);
|
|
71
|
+
}
|
|
72
|
+
return labels.filter((label) => !covered.has(label));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildVerdictRetryHeader(missing: string[]): string {
|
|
76
|
+
return `IMPORTANT: Your previous output (quoted below) is rejected because it does not prove you evaluated every proposal: no verdict was found for ${missing.join(", ")}. Re-read those proposals from your input now and re-emit the COMPLETE plan including a \`## Proposer Verdicts\` section with one \`- **Proposer N:** adopted | partial | rejected — reason\` bullet for every proposal you received. Do not drop or alter the rest of the plan beyond what re-evaluating those proposals requires.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildVerdictRetryTask(originalTask: string, previousOutput: string, missing: string[]): string {
|
|
80
|
+
return [originalTask, "", "---", "", buildRetryCorrection(buildVerdictRetryHeader(missing), previousOutput)].join("\n");
|
|
81
|
+
}
|