@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,89 @@
|
|
|
1
|
+
import { buildRetryCorrection } from "../moa/planlessRetry.ts";
|
|
2
|
+
|
|
3
|
+
export const DEBATE_TASK_PREAMBLE =
|
|
4
|
+
"You are a read-only DEBATER in a multi-round debate between several models. Your output is shown verbatim to the user and reaches your peer debaters — there is no judge, and no agent will merge or verify anything — so argue to convince your peers. You may keep or change your stance in any round. Do not implement anything, edit files, or run commands, builds, or tests; read-only tools are expected and never a blocker. You are running headless: resolve open questions with stated assumptions rather than asking the user or waiting. The required output sections (including the `## Position` argument and the `## Stance` declaration with its `**Stance:**` value) are spelled out in the agent instructions.";
|
|
5
|
+
|
|
6
|
+
/** Matches the required position argument heading, tolerating renamed sections. */
|
|
7
|
+
export const DEBATE_POSITION_HEADING = /^#{1,6}\s.*\b(position)\b/im;
|
|
8
|
+
|
|
9
|
+
/** Matches the stance declaration, e.g. `**Stance:** kept`. */
|
|
10
|
+
export const DEBATE_STANCE_LINE = /\*\*Stance:\*\*\s*(initial|kept|switched|refined)/i;
|
|
11
|
+
|
|
12
|
+
export function looksLikeDebatePosition(output: string): boolean {
|
|
13
|
+
return DEBATE_POSITION_HEADING.test(output) && DEBATE_STANCE_LINE.test(output);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type DebateStance = "initial" | "kept" | "switched" | "refined" | "unknown";
|
|
17
|
+
|
|
18
|
+
export interface ParsedStance {
|
|
19
|
+
stance: DebateStance;
|
|
20
|
+
/** Raw `Debater N` value from the `**Sides with:**` line, when present. */
|
|
21
|
+
sidesWith: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseStance(output: string): ParsedStance {
|
|
25
|
+
const stanceMatch = output.match(DEBATE_STANCE_LINE);
|
|
26
|
+
const sidesMatch = output.match(/\*\*Sides with:\*\*\s*(.+)/i);
|
|
27
|
+
const stance = (stanceMatch?.[1]?.toLowerCase() ?? "unknown") as DebateStance;
|
|
28
|
+
const sidesWith = sidesMatch?.[1]?.trim();
|
|
29
|
+
return { stance, sidesWith: sidesWith && sidesWith.length > 0 ? sidesWith : undefined };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Anonymous slot label — debaters never see each other's real model names. */
|
|
33
|
+
export function debaterLabel(index: number): string {
|
|
34
|
+
return `Debater ${index + 1}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Per-prior cap so five priors of long positions stay well under the runner's task-size warning. */
|
|
38
|
+
const MAX_PRIOR_CHARS = 16_000;
|
|
39
|
+
|
|
40
|
+
function quotePrior(text: string): string {
|
|
41
|
+
const trimmed = text.trim();
|
|
42
|
+
const prior = trimmed.length > MAX_PRIOR_CHARS
|
|
43
|
+
? `${trimmed.slice(0, MAX_PRIOR_CHARS)}\n[prior position truncated]`
|
|
44
|
+
: trimmed;
|
|
45
|
+
return `"""\n${prior.replaceAll('"""', "''")}\n"""`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildOpeningTask(topic: string, index: number, totalDebaters: number): string {
|
|
49
|
+
return [
|
|
50
|
+
DEBATE_TASK_PREAMBLE,
|
|
51
|
+
"",
|
|
52
|
+
`Topic: ${topic}`,
|
|
53
|
+
`You are ${debaterLabel(index)} of ${totalDebaters} debaters. This is round 1: no other debater has spoken yet, so form your own independent, repo-grounded position.`,
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildRoundTask(
|
|
58
|
+
topic: string,
|
|
59
|
+
index: number,
|
|
60
|
+
round: number,
|
|
61
|
+
totalRounds: number,
|
|
62
|
+
ownPrior: string,
|
|
63
|
+
others: { label: string; text: string }[],
|
|
64
|
+
): string {
|
|
65
|
+
const parts = [
|
|
66
|
+
DEBATE_TASK_PREAMBLE,
|
|
67
|
+
"",
|
|
68
|
+
`Topic: ${topic}`,
|
|
69
|
+
`You are ${debaterLabel(index)}. This is round ${round} of ${totalRounds}.`,
|
|
70
|
+
"",
|
|
71
|
+
`Your prior position (round ${round - 1}):`,
|
|
72
|
+
quotePrior(ownPrior),
|
|
73
|
+
];
|
|
74
|
+
for (const other of others) {
|
|
75
|
+
parts.push("", `${other.label} (round ${round - 1}):`, quotePrior(other.text));
|
|
76
|
+
}
|
|
77
|
+
parts.push(
|
|
78
|
+
"",
|
|
79
|
+
"Respond to every other debater's position, revise your argument as warranted, and declare your stance explicitly. You may keep or change your stance.",
|
|
80
|
+
);
|
|
81
|
+
return parts.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const DEBATE_RETRY_HEADER =
|
|
85
|
+
"IMPORTANT: You already attempted this debate task once and stopped without emitting a complete position. You are running headless: do not ask questions, wait for clarification, or refuse because only read-only tools are available. Re-emit the COMPLETE position now, including the required `## Position` argument and the `## Stance` declaration with its `**Stance:**` value.";
|
|
86
|
+
|
|
87
|
+
export function buildDebateRetryTask(originalTask: string, previousOutput: string): string {
|
|
88
|
+
return [originalTask, "", "---", "", buildRetryCorrection(DEBATE_RETRY_HEADER, previousOutput)].join("\n");
|
|
89
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { AgentConfig } from "../agents/discovery.ts";
|
|
4
|
+
import { modelExtensionOptions, resolveContextWindow, resolveModelCost } from "../moa/modelRuntime.ts";
|
|
5
|
+
import type { CancelSession } from "../runtime/cancelRun.ts";
|
|
6
|
+
import { CancelRun } from "../runtime/cancelRun.ts";
|
|
7
|
+
import { getFinalOutput, getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
|
|
8
|
+
import { runParallelAgentsWithModels, type ModelParallelAgentTask } from "../runtime/runner.ts";
|
|
9
|
+
import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
10
|
+
import { activityLoopCount } from "../ui/agentStatus.ts";
|
|
11
|
+
import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
12
|
+
import type { ObserveSession } from "../ui/observeOverlay.ts";
|
|
13
|
+
import {
|
|
14
|
+
buildDebateRetryTask,
|
|
15
|
+
buildOpeningTask,
|
|
16
|
+
looksLikeDebatePosition,
|
|
17
|
+
parseStance,
|
|
18
|
+
} from "./debateContract.ts";
|
|
19
|
+
import type { DebateRound } from "./debateRounds.ts";
|
|
20
|
+
import { buildNextRoundInputs, shouldStopEarly } from "./debateRounds.ts";
|
|
21
|
+
|
|
22
|
+
export interface DebateFanoutHost {
|
|
23
|
+
getActiveObserveSession(): ObserveSession | undefined;
|
|
24
|
+
setActiveObserveSession(session: ObserveSession | undefined): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DebateFanoutOptions {
|
|
28
|
+
host: DebateFanoutHost;
|
|
29
|
+
ctx: ExtensionContext;
|
|
30
|
+
topic: string;
|
|
31
|
+
models: ModelRef[];
|
|
32
|
+
thinking: (ThinkingLevel | undefined)[];
|
|
33
|
+
rounds: number;
|
|
34
|
+
session: CancelSession;
|
|
35
|
+
widget: MoaProgressWidget;
|
|
36
|
+
agents: AgentConfig[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DebateFanoutResult =
|
|
40
|
+
| { status: "done"; rounds: DebateRound[]; stoppedEarly?: string }
|
|
41
|
+
| { status: "cancelled" };
|
|
42
|
+
|
|
43
|
+
interface CumulativeUsage {
|
|
44
|
+
turns: number;
|
|
45
|
+
toolCalls: number;
|
|
46
|
+
costUsd: number | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Runs the debate's round loop: every round, each surviving debater gets a
|
|
51
|
+
* fresh subprocess fed every other survivor's labeled prior position. Each
|
|
52
|
+
* round's subprocess replaces the widget row, so TURNS/TOOLS/COST readings
|
|
53
|
+
* accumulate across rounds per slot.
|
|
54
|
+
*/
|
|
55
|
+
export async function runDebateRounds(options: DebateFanoutOptions): Promise<DebateFanoutResult> {
|
|
56
|
+
const { host, ctx, topic, models, thinking, rounds: totalRounds, session, widget, agents } = options;
|
|
57
|
+
const alive = models.map(() => true);
|
|
58
|
+
const positions: (string | undefined)[] = models.map(() => undefined);
|
|
59
|
+
const completedRounds: DebateRound[] = [];
|
|
60
|
+
const cumulative: CumulativeUsage[] = models.map(() => ({ turns: 0, toolCalls: 0, costUsd: undefined }));
|
|
61
|
+
|
|
62
|
+
const observe: ObserveSession = {
|
|
63
|
+
title: "MoA debate",
|
|
64
|
+
phase: "fanout",
|
|
65
|
+
agents: models.map((ref) => ({
|
|
66
|
+
label: ref.id,
|
|
67
|
+
model: ref.id,
|
|
68
|
+
task: "",
|
|
69
|
+
messages: [],
|
|
70
|
+
state: "working",
|
|
71
|
+
})),
|
|
72
|
+
overlayOpen: false,
|
|
73
|
+
};
|
|
74
|
+
const previousObserveSession = host.getActiveObserveSession();
|
|
75
|
+
host.setActiveObserveSession(observe);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const runBatch = async (
|
|
79
|
+
entries: { index: number; task: string }[],
|
|
80
|
+
currentRun: CancelRun,
|
|
81
|
+
): Promise<SingleResult[]> => {
|
|
82
|
+
const unsubscribe = currentRun.onChange(() => {
|
|
83
|
+
currentRun.agents.forEach((agent, batchIndex) => {
|
|
84
|
+
if (agent.state === "cancelling") widget.update(entries[batchIndex].index, "cancelling");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
session.run = currentRun;
|
|
88
|
+
try {
|
|
89
|
+
const tasks: ModelParallelAgentTask[] = entries.map((entry) => {
|
|
90
|
+
const ref = models[entry.index];
|
|
91
|
+
return {
|
|
92
|
+
agent: "moa-debater",
|
|
93
|
+
task: entry.task,
|
|
94
|
+
model: modelRefLabel(ref),
|
|
95
|
+
thinking: thinking[entry.index],
|
|
96
|
+
...modelExtensionOptions(ctx, ref),
|
|
97
|
+
signal: currentRun.add(modelRefLabel(ref)).signal,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
return await runParallelAgentsWithModels(
|
|
101
|
+
ctx.cwd,
|
|
102
|
+
agents,
|
|
103
|
+
tasks,
|
|
104
|
+
undefined,
|
|
105
|
+
(batchIndex, result) => {
|
|
106
|
+
const debateIndex = entries[batchIndex].index;
|
|
107
|
+
widget.updateTranscript(debateIndex, result.messages);
|
|
108
|
+
const observed = observe.agents[debateIndex];
|
|
109
|
+
if (observed) {
|
|
110
|
+
observed.messages = result.messages;
|
|
111
|
+
observed.partial = undefined;
|
|
112
|
+
}
|
|
113
|
+
if (result.cancelled) {
|
|
114
|
+
currentRun.settle(batchIndex, "cancelled");
|
|
115
|
+
widget.update(debateIndex, "cancelled");
|
|
116
|
+
if (observed) observed.state = "cancelled";
|
|
117
|
+
} else if (isFailedResult(result)) {
|
|
118
|
+
currentRun.settle(batchIndex, "error");
|
|
119
|
+
widget.update(debateIndex, "error", getResultOutput(result).slice(0, 80));
|
|
120
|
+
if (observed) observed.state = "error";
|
|
121
|
+
} else {
|
|
122
|
+
currentRun.settle(batchIndex, "done");
|
|
123
|
+
widget.update(debateIndex, "done");
|
|
124
|
+
if (observed) observed.state = "done";
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
(batchIndex, result) => {
|
|
128
|
+
const debateIndex = entries[batchIndex].index;
|
|
129
|
+
const base = cumulative[debateIndex];
|
|
130
|
+
const roundCost = resolveModelCost(ctx, models[debateIndex], result.usage);
|
|
131
|
+
widget.updateUsage(
|
|
132
|
+
debateIndex,
|
|
133
|
+
result.usage.contextTokens,
|
|
134
|
+
base.turns + result.usage.turns,
|
|
135
|
+
base.toolCalls + result.usage.toolCalls,
|
|
136
|
+
base.costUsd === undefined && roundCost === undefined
|
|
137
|
+
? undefined
|
|
138
|
+
: (base.costUsd ?? 0) + (roundCost ?? 0),
|
|
139
|
+
);
|
|
140
|
+
if (result.activity) widget.updateActivity(debateIndex, result.activity);
|
|
141
|
+
if (result.outputActivity) widget.updateOutput(debateIndex, result.outputActivity.tokens, result.outputActivity.revision);
|
|
142
|
+
widget.updateTranscript(debateIndex, result.messages, result.partialAssistant);
|
|
143
|
+
const observed = observe.agents[debateIndex];
|
|
144
|
+
if (observed) {
|
|
145
|
+
observed.messages = result.messages;
|
|
146
|
+
observed.partial = result.partialAssistant;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
unsubscribe();
|
|
152
|
+
session.run = undefined;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const makeGetExtras = (entries: { index: number }[]) => (batchIndex: number) => {
|
|
157
|
+
const debateIndex = entries[batchIndex].index;
|
|
158
|
+
const status = widget.getStatus(debateIndex);
|
|
159
|
+
if (!status) return undefined;
|
|
160
|
+
return {
|
|
161
|
+
contextTokens: status.contextTokens,
|
|
162
|
+
contextWindow: resolveContextWindow(ctx, status.ref),
|
|
163
|
+
activity: status.activity,
|
|
164
|
+
loopCount: activityLoopCount(status.activity, status.activityHistory),
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const foldUsage = (entries: { index: number }[], results: SingleResult[]): void => {
|
|
169
|
+
for (let batchIndex = 0; batchIndex < results.length; batchIndex++) {
|
|
170
|
+
const debateIndex = entries[batchIndex].index;
|
|
171
|
+
const base = cumulative[debateIndex];
|
|
172
|
+
const usage = results[batchIndex].usage;
|
|
173
|
+
const cost = resolveModelCost(ctx, models[debateIndex], usage);
|
|
174
|
+
cumulative[debateIndex] = {
|
|
175
|
+
turns: base.turns + usage.turns,
|
|
176
|
+
toolCalls: base.toolCalls + usage.toolCalls,
|
|
177
|
+
costUsd: base.costUsd === undefined && cost === undefined ? undefined : (base.costUsd ?? 0) + (cost ?? 0),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
widget.startFanout(models, thinking);
|
|
183
|
+
|
|
184
|
+
let stoppedEarly: string | undefined;
|
|
185
|
+
for (let round = 1; round <= totalRounds; round++) {
|
|
186
|
+
const entries: { index: number; task: string }[] = round === 1
|
|
187
|
+
? models.map((_, index) => ({
|
|
188
|
+
index,
|
|
189
|
+
task: buildOpeningTask(topic, index, models.length),
|
|
190
|
+
}))
|
|
191
|
+
: buildNextRoundInputs(topic, completedRounds[completedRounds.length - 1], round, totalRounds);
|
|
192
|
+
|
|
193
|
+
if (round > 1) {
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
// Re-activates the settled row and resets its transcript.
|
|
196
|
+
widget.update(entry.index, "working", `round ${round}/${totalRounds} · responding`);
|
|
197
|
+
const observed = observe.agents[entry.index];
|
|
198
|
+
if (observed) {
|
|
199
|
+
observed.state = "working";
|
|
200
|
+
observed.task = entry.task;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
for (const entry of entries) observe.agents[entry.index].task = entry.task;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const run = new CancelRun();
|
|
208
|
+
session.title = `Debate agents — round ${round}/${totalRounds}`;
|
|
209
|
+
session.getExtras = makeGetExtras(entries);
|
|
210
|
+
let results = await runBatch(entries, run);
|
|
211
|
+
if (run.cancelAllRequested) return { status: "cancelled" };
|
|
212
|
+
|
|
213
|
+
// One-shot retry for survivors whose output lacks the position/stance contract.
|
|
214
|
+
const retryEntries: { index: number; task: string }[] = [];
|
|
215
|
+
const retriedEntries: { index: number }[] = [];
|
|
216
|
+
const retriedResults: SingleResult[] = [];
|
|
217
|
+
for (let batchIndex = 0; batchIndex < results.length; batchIndex++) {
|
|
218
|
+
const result = results[batchIndex];
|
|
219
|
+
if (result.cancelled || isFailedResult(result)) continue;
|
|
220
|
+
if (looksLikeDebatePosition(getFinalOutput(result.messages))) continue;
|
|
221
|
+
const entry = entries[batchIndex];
|
|
222
|
+
retryEntries.push({
|
|
223
|
+
index: entry.index,
|
|
224
|
+
task: buildDebateRetryTask(entry.task, getFinalOutput(result.messages)),
|
|
225
|
+
});
|
|
226
|
+
retriedEntries.push(entry);
|
|
227
|
+
retriedResults.push(result);
|
|
228
|
+
}
|
|
229
|
+
if (retryEntries.length > 0) {
|
|
230
|
+
// The first attempt genuinely spent tokens; fold it in before the retry overwrites it.
|
|
231
|
+
foldUsage(retriedEntries, retriedResults);
|
|
232
|
+
const retryRun = new CancelRun();
|
|
233
|
+
session.title = `Debate agents — round ${round}/${totalRounds} (retry)`;
|
|
234
|
+
session.getExtras = makeGetExtras(retryEntries);
|
|
235
|
+
for (const entry of retryEntries) {
|
|
236
|
+
widget.update(entry.index, "working", "retrying: no position produced");
|
|
237
|
+
const observed = observe.agents[entry.index];
|
|
238
|
+
if (observed) {
|
|
239
|
+
observed.state = "working";
|
|
240
|
+
observed.task = entry.task;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const retryResults = await runBatch(retryEntries, retryRun);
|
|
244
|
+
for (let batchIndex = 0; batchIndex < retryResults.length; batchIndex++) {
|
|
245
|
+
const slot = entries.findIndex((entry) => entry.index === retryEntries[batchIndex].index);
|
|
246
|
+
results[slot] = retryResults[batchIndex];
|
|
247
|
+
}
|
|
248
|
+
if (retryRun.cancelAllRequested) return { status: "cancelled" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
foldUsage(entries, results);
|
|
252
|
+
|
|
253
|
+
const outcomes = entries
|
|
254
|
+
.map((entry, batchIndex) => ({ entry, result: results[batchIndex] }))
|
|
255
|
+
.sort((a, b) => a.entry.index - b.entry.index)
|
|
256
|
+
.map(({ entry, result }) => {
|
|
257
|
+
if (result.cancelled) {
|
|
258
|
+
alive[entry.index] = false;
|
|
259
|
+
return { index: entry.index, status: "cancelled" as const, text: getResultOutput(result) };
|
|
260
|
+
}
|
|
261
|
+
if (isFailedResult(result)) {
|
|
262
|
+
alive[entry.index] = false;
|
|
263
|
+
return { index: entry.index, status: "error" as const, text: getResultOutput(result) };
|
|
264
|
+
}
|
|
265
|
+
const text = getFinalOutput(result.messages).trim() || "(no output)";
|
|
266
|
+
positions[entry.index] = text;
|
|
267
|
+
return { index: entry.index, status: "done" as const, text, stance: parseStance(text) };
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const roundRecord: DebateRound = { round, outcomes };
|
|
271
|
+
completedRounds.push(roundRecord);
|
|
272
|
+
|
|
273
|
+
const stop = shouldStopEarly(roundRecord);
|
|
274
|
+
if (stop.stop) {
|
|
275
|
+
stoppedEarly = stop.reason;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { status: "done", rounds: completedRounds, stoppedEarly };
|
|
281
|
+
} finally {
|
|
282
|
+
observe.closeOverlay?.();
|
|
283
|
+
host.setActiveObserveSession(previousObserveSession);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isValidPlanSlug } from "../planning/planFile.ts";
|
|
5
|
+
|
|
6
|
+
export type RepoDebateFileKind = "debate-prompt" | "debate";
|
|
7
|
+
|
|
8
|
+
function repoDebateSuffix(kind: RepoDebateFileKind): string {
|
|
9
|
+
return kind === "debate-prompt" ? "__debate-prompt" : "__debate";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getRepoDebateDirectory(repoCwd: string): string {
|
|
13
|
+
const debateDir = path.join(repoCwd, CONFIG_DIR_NAME, "mf-debate");
|
|
14
|
+
try {
|
|
15
|
+
fs.mkdirSync(debateDir, { recursive: true });
|
|
16
|
+
} catch {
|
|
17
|
+
// Best effort; the write will report a useful error if this failed.
|
|
18
|
+
}
|
|
19
|
+
return debateDir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveRepoDebateFile(
|
|
23
|
+
content: string,
|
|
24
|
+
repoCwd: string,
|
|
25
|
+
baseSlug: string,
|
|
26
|
+
kind: RepoDebateFileKind,
|
|
27
|
+
): string {
|
|
28
|
+
if (!isValidPlanSlug(baseSlug)) throw new Error("Invalid repository debate slug");
|
|
29
|
+
const debateDir = getRepoDebateDirectory(repoCwd);
|
|
30
|
+
const filePath = path.join(debateDir, `${baseSlug}${repoDebateSuffix(kind)}.md`);
|
|
31
|
+
fs.writeFileSync(filePath, content, { encoding: "utf-8" });
|
|
32
|
+
return filePath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function repoDebateDisplayPath(baseSlug: string, kind: RepoDebateFileKind): string {
|
|
36
|
+
if (!isValidPlanSlug(baseSlug)) throw new Error("Invalid repository debate slug");
|
|
37
|
+
return `${CONFIG_DIR_NAME}/mf-debate/${baseSlug}${repoDebateSuffix(kind)}.md`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ModelRef, ThinkingLevel } from "../shared/modelRefs.ts";
|
|
2
|
+
import { modelRefLabel } from "../shared/modelRefs.ts";
|
|
3
|
+
import { debaterLabel, type ParsedStance } from "./debateContract.ts";
|
|
4
|
+
import { demoteHeadings } from "../opinion/opinionResults.ts";
|
|
5
|
+
import type { DebateRound } from "./debateRounds.ts";
|
|
6
|
+
import { parseStance } from "./debateContract.ts";
|
|
7
|
+
|
|
8
|
+
export interface DebateOutcome {
|
|
9
|
+
ref: ModelRef;
|
|
10
|
+
thinking: ThinkingLevel | undefined;
|
|
11
|
+
/** State at debate close: still standing, or how it left. */
|
|
12
|
+
finalStatus: "active-at-close" | "error" | "cancelled";
|
|
13
|
+
finalStance?: ParsedStance;
|
|
14
|
+
/** Last round this debater produced output, 0 if never. */
|
|
15
|
+
lastRoundActive: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function collectDebateOutcomes(
|
|
19
|
+
models: ModelRef[],
|
|
20
|
+
thinking: (ThinkingLevel | undefined)[],
|
|
21
|
+
rounds: DebateRound[],
|
|
22
|
+
): DebateOutcome[] {
|
|
23
|
+
return models.map((ref, index) => {
|
|
24
|
+
let finalStatus: DebateOutcome["finalStatus"] = "active-at-close";
|
|
25
|
+
let lastRoundActive = 0;
|
|
26
|
+
let finalStance: ParsedStance | undefined;
|
|
27
|
+
for (const round of rounds) {
|
|
28
|
+
const outcome = round.outcomes.find((entry) => entry.index === index);
|
|
29
|
+
if (!outcome) continue;
|
|
30
|
+
if (outcome.status === "done") {
|
|
31
|
+
lastRoundActive = round.round;
|
|
32
|
+
finalStance = outcome.stance;
|
|
33
|
+
} else {
|
|
34
|
+
finalStatus = outcome.status;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ref, thinking: thinking[index], finalStatus, finalStance, lastRoundActive };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function firstLine(text: string): string {
|
|
42
|
+
return text.trim().split(/\r?\n/, 1)[0] || "Unknown error";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractPositionParagraph(output: string): string {
|
|
46
|
+
const match = output.match(/^#{1,6}\s.*\bposition\b.*$/im);
|
|
47
|
+
if (!match || match.index === undefined) return output.trim();
|
|
48
|
+
const rest = output.slice(match.index + match[0].length);
|
|
49
|
+
const nextHeading = rest.search(/^#{1,6}\s/m);
|
|
50
|
+
return (nextHeading >= 0 ? rest.slice(0, nextHeading) : rest).trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const STANCE_LABELS: Record<string, string> = {
|
|
54
|
+
initial: "initial position",
|
|
55
|
+
kept: "kept position",
|
|
56
|
+
switched: "switched stance",
|
|
57
|
+
refined: "refined position",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function formatDebateMarkdown(
|
|
61
|
+
topic: string,
|
|
62
|
+
outcomes: DebateOutcome[],
|
|
63
|
+
rounds: DebateRound[],
|
|
64
|
+
slug: string,
|
|
65
|
+
closingReason: string | undefined,
|
|
66
|
+
totalRoundsRequested: number,
|
|
67
|
+
): string {
|
|
68
|
+
const roster = outcomes.map((outcome, index) => {
|
|
69
|
+
const thinking = outcome.thinking ?? "default";
|
|
70
|
+
return `${debaterLabel(index)} = ${modelRefLabel(outcome.ref)} (thinking: ${thinking})`;
|
|
71
|
+
}).join(" · ");
|
|
72
|
+
|
|
73
|
+
const roundSections = rounds.map((round) => {
|
|
74
|
+
const contributions = round.outcomes.map((outcome) => {
|
|
75
|
+
const label = debaterLabel(outcome.index);
|
|
76
|
+
const ref = outcomes[outcome.index]?.ref;
|
|
77
|
+
const thinking = outcomes[outcome.index]?.thinking ?? "default";
|
|
78
|
+
const heading = `### ${label} — ${ref ? modelRefLabel(ref) : "unknown"} (thinking: ${thinking})`;
|
|
79
|
+
if (outcome.status === "cancelled") return `${heading}\n\n_Cancelled by user._`;
|
|
80
|
+
if (outcome.status === "error") return `${heading}\n\n_Failed: ${firstLine(outcome.text)}_`;
|
|
81
|
+
return `${heading}\n\n${demoteHeadings(outcome.text)}`;
|
|
82
|
+
});
|
|
83
|
+
return [`## Round ${round.round}`, contributions.join("\n\n")].join("\n\n");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const finalPositions = outcomes
|
|
87
|
+
.map((outcome, index) => {
|
|
88
|
+
if (outcome.finalStatus !== "active-at-close" || outcome.lastRoundActive === 0) return undefined;
|
|
89
|
+
const prior = rounds[outcome.lastRoundActive - 1]?.outcomes.find((entry) => entry.index === index);
|
|
90
|
+
if (!prior || prior.status !== "done") return undefined;
|
|
91
|
+
const label = debaterLabel(index);
|
|
92
|
+
const stance = outcome.finalStance?.stance ?? parseStance(prior.text).stance;
|
|
93
|
+
const stanceLabel = STANCE_LABELS[stance] ?? stance;
|
|
94
|
+
const position = extractPositionParagraph(prior.text);
|
|
95
|
+
return `- **${label}** (${stanceLabel}): ${position}`;
|
|
96
|
+
})
|
|
97
|
+
.filter((line) => line !== undefined);
|
|
98
|
+
|
|
99
|
+
const closedLine = closingReason
|
|
100
|
+
? `Closed early after ${rounds.length} of ${totalRoundsRequested} rounds — ${closingReason}`
|
|
101
|
+
: `Completed all ${totalRoundsRequested} rounds`;
|
|
102
|
+
|
|
103
|
+
const parts = [
|
|
104
|
+
`# MoA Debate — ${slug}`,
|
|
105
|
+
"",
|
|
106
|
+
`**Topic:** ${topic}`,
|
|
107
|
+
"",
|
|
108
|
+
`**Roster:** ${roster}`,
|
|
109
|
+
"",
|
|
110
|
+
`**Closed:** ${closedLine} · no judge — positions below are the debaters' own`,
|
|
111
|
+
roundSections.join("\n\n"),
|
|
112
|
+
];
|
|
113
|
+
if (finalPositions.length > 0) parts.push("", "## Final Positions", finalPositions.join("\n"));
|
|
114
|
+
return parts.join("\n").trimEnd();
|
|
115
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { buildRoundTask, parseStance, type ParsedStance } from "./debateContract.ts";
|
|
2
|
+
|
|
3
|
+
/** What one debater produced (or failed to produce) in one round. */
|
|
4
|
+
export interface DebaterRoundOutcome {
|
|
5
|
+
index: number;
|
|
6
|
+
status: "done" | "error" | "cancelled";
|
|
7
|
+
text: string;
|
|
8
|
+
stance?: ParsedStance;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DebateRound {
|
|
12
|
+
round: number;
|
|
13
|
+
outcomes: DebaterRoundOutcome[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Slots whose last round completed — the only ones carried into the next round. */
|
|
17
|
+
export function survivingIndices(round: DebateRound): number[] {
|
|
18
|
+
return round.outcomes.filter((outcome) => outcome.status === "done").map((outcome) => outcome.index);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stop conditions checked after every round: fewer than two survivors means
|
|
23
|
+
* no debate is possible, and (from round 2 on, since nobody has moved yet in
|
|
24
|
+
* round 1) a unanimous "kept" round means nobody moved.
|
|
25
|
+
*/
|
|
26
|
+
export function shouldStopEarly(round: DebateRound): { stop: boolean; reason?: string } {
|
|
27
|
+
const survivors = round.outcomes.filter((outcome) => outcome.status === "done");
|
|
28
|
+
if (survivors.length < 2) return { stop: true, reason: "fewer than two debaters remain" };
|
|
29
|
+
if (round.round >= 2 && survivors.every((outcome) => outcome.stance?.stance === "kept")) {
|
|
30
|
+
return { stop: true, reason: "all debaters held their positions" };
|
|
31
|
+
}
|
|
32
|
+
return { stop: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-survivor task text for the next round: each survivor receives its own
|
|
37
|
+
* prior plus every other survivor's labeled text. Settled (errored/cancelled)
|
|
38
|
+
* agents that never produced text contribute nothing.
|
|
39
|
+
*/
|
|
40
|
+
export function buildNextRoundInputs(
|
|
41
|
+
topic: string,
|
|
42
|
+
previousRound: DebateRound,
|
|
43
|
+
round: number,
|
|
44
|
+
totalRounds: number,
|
|
45
|
+
): { index: number; task: string }[] {
|
|
46
|
+
const survivors = survivingIndices(previousRound);
|
|
47
|
+
const priorOf = new Map<number, string>();
|
|
48
|
+
for (const outcome of previousRound.outcomes) {
|
|
49
|
+
if (outcome.status === "done" && outcome.text.trim().length > 0) priorOf.set(outcome.index, outcome.text);
|
|
50
|
+
}
|
|
51
|
+
return survivors.map((index) => {
|
|
52
|
+
const ownPrior = priorOf.get(index) ?? "";
|
|
53
|
+
const others = survivors
|
|
54
|
+
.filter((other) => other !== index)
|
|
55
|
+
.filter((other) => priorOf.has(other))
|
|
56
|
+
.map((other) => ({ label: `Debater ${other + 1}`, text: priorOf.get(other)! }));
|
|
57
|
+
return { index, task: buildRoundTask(topic, index, round, totalRounds, ownPrior, others) };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { parseStance };
|