@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,152 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { AgentConfig } from "../agents/discovery.ts";
|
|
4
|
+
import type { CancelSession } from "../runtime/cancelRun.ts";
|
|
5
|
+
import { CancelRun } from "../runtime/cancelRun.ts";
|
|
6
|
+
import { getFinalOutput, isFailedResult } from "../runtime/results.ts";
|
|
7
|
+
import type { ModelParallelAgentTask } from "../runtime/runner.ts";
|
|
8
|
+
import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
9
|
+
import { activityLoopCount } from "../ui/agentStatus.ts";
|
|
10
|
+
import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
11
|
+
import type { ObserveSession } from "../ui/observeOverlay.ts";
|
|
12
|
+
import { runWidgetFanout } from "./fanoutWiring.ts";
|
|
13
|
+
import { buildProposerRetryTask, buildProposerTask, looksLikePlan } from "./planlessRetry.ts";
|
|
14
|
+
import { modelExtensionOptions, resolveContextWindow } from "./modelRuntime.ts";
|
|
15
|
+
import type { MoaRunContext, MoaRunHost, SucceededProposal } from "./runContext.ts";
|
|
16
|
+
|
|
17
|
+
export type FanoutPhaseResult =
|
|
18
|
+
| { status: "continue"; succeeded: SucceededProposal[]; observe: ObserveSession; previousObserveSession: ObserveSession | undefined }
|
|
19
|
+
| { status: "done" | "cancelled"; observe: ObserveSession; previousObserveSession: ObserveSession | undefined };
|
|
20
|
+
|
|
21
|
+
export interface FanoutPhaseOptions {
|
|
22
|
+
host: MoaRunHost;
|
|
23
|
+
runContext: Pick<MoaRunContext, "observeSession" | "previousObserveSession">;
|
|
24
|
+
ctx: ExtensionContext;
|
|
25
|
+
prompt: string;
|
|
26
|
+
proposers: ModelRef[];
|
|
27
|
+
proposerThinking: (ThinkingLevel | undefined)[];
|
|
28
|
+
session: CancelSession;
|
|
29
|
+
widget: MoaProgressWidget;
|
|
30
|
+
agents: AgentConfig[];
|
|
31
|
+
warnIfMutated(phase: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
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);
|
|
37
|
+
const observe: ObserveSession = {
|
|
38
|
+
title: "MoA fan-out",
|
|
39
|
+
phase: "fanout",
|
|
40
|
+
agents: proposers.map((ref) => {
|
|
41
|
+
// The observer is display-only: omit the provider while task routing
|
|
42
|
+
// continues to use modelRefLabel(ref) below.
|
|
43
|
+
return { label: ref.id, model: ref.id, task: prompt, messages: [], state: "working" };
|
|
44
|
+
}),
|
|
45
|
+
overlayOpen: false,
|
|
46
|
+
};
|
|
47
|
+
const previousObserveSession = host.getActiveObserveSession();
|
|
48
|
+
// Publish ownership before fan-out can await or throw so runMoaPhases' outer
|
|
49
|
+
// finally always restores the predecessor and closes this observer.
|
|
50
|
+
runContext.previousObserveSession = previousObserveSession;
|
|
51
|
+
runContext.observeSession = observe;
|
|
52
|
+
host.setActiveObserveSession(observe);
|
|
53
|
+
|
|
54
|
+
const run = new CancelRun();
|
|
55
|
+
session.title = "MoA fan-out";
|
|
56
|
+
session.getExtras = (i) => {
|
|
57
|
+
const status = widget.getStatus(i);
|
|
58
|
+
if (!status) return undefined;
|
|
59
|
+
return {
|
|
60
|
+
contextTokens: status.contextTokens,
|
|
61
|
+
contextWindow: resolveContextWindow(ctx, status.ref),
|
|
62
|
+
activity: status.activity,
|
|
63
|
+
loopCount: activityLoopCount(status.activity, status.activityHistory),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const runFanout = (tasks: ModelParallelAgentTask[], indexMap: (index: number) => number, currentRun: CancelRun) =>
|
|
67
|
+
runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models: proposers, indexMap });
|
|
68
|
+
|
|
69
|
+
// The planning contract travels inside the task text — the only channel
|
|
70
|
+
// guaranteed to survive a bridge's system-prompt override. The observer keeps
|
|
71
|
+
// the raw prompt so its 120-char header stays readable.
|
|
72
|
+
const proposerTask = buildProposerTask(prompt);
|
|
73
|
+
const tasks = proposers.map((ref, index) => {
|
|
74
|
+
const label = modelRefLabel(ref);
|
|
75
|
+
return {
|
|
76
|
+
agent: "moa-proposer",
|
|
77
|
+
task: proposerTask,
|
|
78
|
+
model: label,
|
|
79
|
+
thinking: proposerThinking[index],
|
|
80
|
+
...modelExtensionOptions(ctx, ref),
|
|
81
|
+
signal: run.add(label).signal,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
const results = await runFanout(tasks, (index) => index, run);
|
|
85
|
+
await warnIfMutated("MoA proposer fan-out");
|
|
86
|
+
if (run.cancelAllRequested) {
|
|
87
|
+
widget.stopWidget();
|
|
88
|
+
ctx.ui.notify("MoA run cancelled.");
|
|
89
|
+
return { status: "cancelled", observe, previousObserveSession };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const retryIndices: number[] = [];
|
|
93
|
+
for (let index = 0; index < results.length; index++) {
|
|
94
|
+
const result = results[index];
|
|
95
|
+
if (!isFailedResult(result) && !result.cancelled && !looksLikePlan(getFinalOutput(result.messages))) retryIndices.push(index);
|
|
96
|
+
}
|
|
97
|
+
if (retryIndices.length > 0) {
|
|
98
|
+
ctx.ui.notify(`MoA: retrying ${retryIndices.length} proposer(s) — no plan produced.`);
|
|
99
|
+
const retryRun = new CancelRun();
|
|
100
|
+
session.title = "MoA fan-out (retry)";
|
|
101
|
+
session.getExtras = (index) => {
|
|
102
|
+
const status = widget.getStatus(retryIndices[index]);
|
|
103
|
+
if (!status) return undefined;
|
|
104
|
+
return {
|
|
105
|
+
contextTokens: status.contextTokens,
|
|
106
|
+
contextWindow: resolveContextWindow(ctx, status.ref),
|
|
107
|
+
activity: status.activity,
|
|
108
|
+
loopCount: activityLoopCount(status.activity, status.activityHistory),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
const retryTasks = retryIndices.map((proposerIndex) => {
|
|
112
|
+
const ref = proposers[proposerIndex];
|
|
113
|
+
const label = modelRefLabel(ref);
|
|
114
|
+
const retryTask = buildProposerRetryTask(proposerTask, getFinalOutput(results[proposerIndex].messages));
|
|
115
|
+
widget.update(proposerIndex, "working", "retrying: no plan produced");
|
|
116
|
+
const observed = observe.agents[proposerIndex];
|
|
117
|
+
if (observed) {
|
|
118
|
+
observed.state = "working";
|
|
119
|
+
observed.task = retryTask;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
agent: "moa-proposer",
|
|
123
|
+
task: retryTask,
|
|
124
|
+
model: label,
|
|
125
|
+
thinking: proposerThinking[proposerIndex],
|
|
126
|
+
...modelExtensionOptions(ctx, ref),
|
|
127
|
+
signal: retryRun.add(label).signal,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
const retryResults = await runFanout(retryTasks, (index) => retryIndices[index], retryRun);
|
|
131
|
+
for (let index = 0; index < retryResults.length; index++) results[retryIndices[index]] = retryResults[index];
|
|
132
|
+
await warnIfMutated("MoA proposer retry");
|
|
133
|
+
if (retryRun.cancelAllRequested) {
|
|
134
|
+
widget.stopWidget();
|
|
135
|
+
ctx.ui.notify("MoA run cancelled.");
|
|
136
|
+
return { status: "cancelled", observe, previousObserveSession };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const succeeded: SucceededProposal[] = [];
|
|
141
|
+
for (let index = 0; index < results.length; index++) {
|
|
142
|
+
const result = results[index];
|
|
143
|
+
if (!isFailedResult(result)) succeeded.push({ ref: proposers[index], plan: getFinalOutput(result.messages), originalIndex: index });
|
|
144
|
+
}
|
|
145
|
+
if (succeeded.length === 0) {
|
|
146
|
+
widget.stopWidget();
|
|
147
|
+
ctx.ui.notify("All MoA proposer models failed. Falling back to single-model planning.", "error");
|
|
148
|
+
host.pi.sendUserMessage(prompt, TRIGGER_TURN);
|
|
149
|
+
return { status: "done", observe, previousObserveSession };
|
|
150
|
+
}
|
|
151
|
+
return { status: "continue", succeeded, observe, previousObserveSession };
|
|
152
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { AgentConfig } from "../agents/discovery.ts";
|
|
4
|
+
import type { CancelRun, CancelSession } from "../runtime/cancelRun.ts";
|
|
5
|
+
import { getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
|
|
6
|
+
import { runParallelAgentsWithModels, type ModelParallelAgentTask } from "../runtime/runner.ts";
|
|
7
|
+
import type { ModelRef } from "../shared/modelRefs.ts";
|
|
8
|
+
import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
9
|
+
import type { ObserveSession } from "../ui/observeOverlay.ts";
|
|
10
|
+
import { resolveModelCost } from "./modelRuntime.ts";
|
|
11
|
+
|
|
12
|
+
export interface WidgetFanoutOptions {
|
|
13
|
+
ctx: ExtensionContext;
|
|
14
|
+
agents: AgentConfig[];
|
|
15
|
+
tasks: ModelParallelAgentTask[];
|
|
16
|
+
run: CancelRun;
|
|
17
|
+
session: CancelSession;
|
|
18
|
+
widget: MoaProgressWidget;
|
|
19
|
+
observe: ObserveSession;
|
|
20
|
+
models: ModelRef[];
|
|
21
|
+
indexMap: (index: number) => number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs a batch through runParallelAgentsWithModels wired to settle the
|
|
26
|
+
* CancelRun and mirror progress into the widget and observe session — the
|
|
27
|
+
* settle/onProgress plumbing shared by every MoA fan-out variant.
|
|
28
|
+
*/
|
|
29
|
+
export async function runWidgetFanout(options: WidgetFanoutOptions): Promise<SingleResult[]> {
|
|
30
|
+
const { ctx, agents, tasks, run, session, widget, observe, models, indexMap } = options;
|
|
31
|
+
const unsubscribe = run.onChange(() => {
|
|
32
|
+
run.agents.forEach((agent, index) => {
|
|
33
|
+
if (agent.state === "cancelling") widget.update(indexMap(index), "cancelling");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
session.run = run;
|
|
37
|
+
try {
|
|
38
|
+
return await runParallelAgentsWithModels(
|
|
39
|
+
ctx.cwd,
|
|
40
|
+
agents,
|
|
41
|
+
tasks,
|
|
42
|
+
undefined,
|
|
43
|
+
(index, result) => {
|
|
44
|
+
const widgetIndex = indexMap(index);
|
|
45
|
+
widget.updateTranscript(widgetIndex, result.messages);
|
|
46
|
+
const observed = observe.agents[widgetIndex];
|
|
47
|
+
if (observed) {
|
|
48
|
+
observed.messages = result.messages;
|
|
49
|
+
observed.partial = undefined;
|
|
50
|
+
}
|
|
51
|
+
if (result.cancelled) {
|
|
52
|
+
run.settle(index, "cancelled");
|
|
53
|
+
widget.update(widgetIndex, "cancelled");
|
|
54
|
+
if (observed) observed.state = "cancelled";
|
|
55
|
+
} else if (isFailedResult(result)) {
|
|
56
|
+
run.settle(index, "error");
|
|
57
|
+
widget.update(widgetIndex, "error", getResultOutput(result).slice(0, 80));
|
|
58
|
+
if (observed) observed.state = "error";
|
|
59
|
+
} else {
|
|
60
|
+
run.settle(index, "done");
|
|
61
|
+
widget.update(widgetIndex, "done");
|
|
62
|
+
if (observed) observed.state = "done";
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
(index, result) => {
|
|
66
|
+
const widgetIndex = indexMap(index);
|
|
67
|
+
widget.updateUsage(
|
|
68
|
+
widgetIndex,
|
|
69
|
+
result.usage.contextTokens,
|
|
70
|
+
result.usage.turns,
|
|
71
|
+
result.usage.toolCalls,
|
|
72
|
+
resolveModelCost(ctx, models[widgetIndex], result.usage),
|
|
73
|
+
);
|
|
74
|
+
if (result.activity) widget.updateActivity(widgetIndex, result.activity);
|
|
75
|
+
if (result.outputActivity) widget.updateOutput(widgetIndex, result.outputActivity.tokens, result.outputActivity.revision);
|
|
76
|
+
widget.updateTranscript(widgetIndex, result.messages, result.partialAssistant);
|
|
77
|
+
const observed = observe.agents[widgetIndex];
|
|
78
|
+
if (observed) {
|
|
79
|
+
observed.messages = result.messages;
|
|
80
|
+
observed.partial = result.partialAssistant;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
} finally {
|
|
85
|
+
unsubscribe();
|
|
86
|
+
session.run = undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
type ModelRef,
|
|
6
|
+
type ThinkingLevel,
|
|
7
|
+
isModelRef,
|
|
8
|
+
isThinkingLevel,
|
|
9
|
+
modelRefLabel,
|
|
10
|
+
TRIGGER_TURN,
|
|
11
|
+
} from "../shared/modelRefs.ts";
|
|
12
|
+
import { getPlan, getPlansDirectory, getRepoPlanDirectory, isValidPlanSlug } from "../planning/planFile.ts";
|
|
13
|
+
import { showImplementingModelPicker } from "../ui/moaModelPicker.ts";
|
|
14
|
+
import type { MoaRunHost } from "./runContext.js";
|
|
15
|
+
|
|
16
|
+
export interface ImplementationHandoff {
|
|
17
|
+
plan: string;
|
|
18
|
+
planFilePath: string;
|
|
19
|
+
repoPlanSlug?: string;
|
|
20
|
+
model?: ModelRef;
|
|
21
|
+
thinking?: ThinkingLevel;
|
|
22
|
+
/** Model that verifies the implementation against the plan once the turn settles. */
|
|
23
|
+
verifier?: ModelRef;
|
|
24
|
+
verifierThinking?: ThinkingLevel;
|
|
25
|
+
/** Frozen markdown checklist generated at approval time for criteria-mode verification. */
|
|
26
|
+
verificationCriteria?: string;
|
|
27
|
+
/** How many verifier-driven repair rounds this handoff has already consumed. */
|
|
28
|
+
verificationRepairs?: number;
|
|
29
|
+
timestamp: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateImplementationHandoff(value: unknown): ImplementationHandoff | undefined {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const candidate = value as Partial<ImplementationHandoff>;
|
|
38
|
+
if (typeof candidate.plan !== "string") {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (typeof candidate.planFilePath !== "string" || candidate.planFilePath.trim().length === 0) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (typeof candidate.timestamp !== "number" || !Number.isFinite(candidate.timestamp) || candidate.timestamp <= 0) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let repoPlanSlug: string | undefined;
|
|
49
|
+
if (candidate.repoPlanSlug !== undefined) {
|
|
50
|
+
if (typeof candidate.repoPlanSlug !== "string" || !isValidPlanSlug(candidate.repoPlanSlug)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
repoPlanSlug = candidate.repoPlanSlug;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let model: ModelRef | undefined;
|
|
57
|
+
if (candidate.model !== undefined) {
|
|
58
|
+
if (!isModelRef(candidate.model)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
model = candidate.model;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let thinking: ThinkingLevel | undefined;
|
|
65
|
+
if (candidate.thinking !== undefined) {
|
|
66
|
+
if (!isThinkingLevel(candidate.thinking)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
thinking = candidate.thinking;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let verifier: ModelRef | undefined;
|
|
73
|
+
if (candidate.verifier !== undefined) {
|
|
74
|
+
if (!isModelRef(candidate.verifier)) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
verifier = candidate.verifier;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let verifierThinking: ThinkingLevel | undefined;
|
|
81
|
+
if (candidate.verifierThinking !== undefined) {
|
|
82
|
+
if (!isThinkingLevel(candidate.verifierThinking)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
verifierThinking = candidate.verifierThinking;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let verificationCriteria: string | undefined;
|
|
89
|
+
if (candidate.verificationCriteria !== undefined) {
|
|
90
|
+
if (typeof candidate.verificationCriteria !== "string" || candidate.verificationCriteria.trim().length === 0) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
verificationCriteria = candidate.verificationCriteria;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let verificationRepairs: number | undefined;
|
|
97
|
+
if (candidate.verificationRepairs !== undefined) {
|
|
98
|
+
if (typeof candidate.verificationRepairs !== "number"
|
|
99
|
+
|| !Number.isInteger(candidate.verificationRepairs)
|
|
100
|
+
|| candidate.verificationRepairs < 0) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
verificationRepairs = candidate.verificationRepairs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
plan: candidate.plan,
|
|
108
|
+
planFilePath: candidate.planFilePath,
|
|
109
|
+
repoPlanSlug,
|
|
110
|
+
model,
|
|
111
|
+
thinking,
|
|
112
|
+
verifier,
|
|
113
|
+
verifierThinking,
|
|
114
|
+
verificationCriteria,
|
|
115
|
+
verificationRepairs,
|
|
116
|
+
timestamp: candidate.timestamp,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function implementationTurnFailed(stopReason: string | undefined): boolean {
|
|
121
|
+
return stopReason === undefined || stopReason === "error";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveHandoffPlan(handoff: ImplementationHandoff, repoCwd?: string): string | null {
|
|
125
|
+
if (handoff.plan && handoff.plan.trim().length > 0) {
|
|
126
|
+
return handoff.plan;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (handoff.planFilePath) {
|
|
130
|
+
try {
|
|
131
|
+
const resolvedPath = path.resolve(handoff.planFilePath);
|
|
132
|
+
const plansDir = path.resolve(getPlansDirectory());
|
|
133
|
+
const repoPlanDir = repoCwd ? path.resolve(getRepoPlanDirectory(repoCwd)) : undefined;
|
|
134
|
+
const withinPlansDir = resolvedPath.startsWith(`${plansDir}${path.sep}`);
|
|
135
|
+
const withinRepoPlanDir = repoPlanDir !== undefined && resolvedPath.startsWith(`${repoPlanDir}${path.sep}`);
|
|
136
|
+
if ((withinPlansDir || withinRepoPlanDir) && fs.existsSync(resolvedPath)) {
|
|
137
|
+
const content = fs.readFileSync(resolvedPath, "utf8");
|
|
138
|
+
if (content.trim().length > 0) {
|
|
139
|
+
return content;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Fall through to repo slug fallback
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (handoff.repoPlanSlug) {
|
|
148
|
+
const currentSlugPlan = getPlan();
|
|
149
|
+
if (currentSlugPlan && currentSlugPlan.trim().length > 0) {
|
|
150
|
+
return currentSlugPlan;
|
|
151
|
+
}
|
|
152
|
+
if (repoCwd) {
|
|
153
|
+
const repoPlanDir = getRepoPlanDirectory(repoCwd);
|
|
154
|
+
const repoFilePath = path.join(repoPlanDir, `${handoff.repoPlanSlug}__plan.md`);
|
|
155
|
+
try {
|
|
156
|
+
if (fs.existsSync(repoFilePath)) {
|
|
157
|
+
const content = fs.readFileSync(repoFilePath, "utf8");
|
|
158
|
+
if (content.trim().length > 0) {
|
|
159
|
+
return content;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// No fallback plan content found
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Delegation directive appended to every implementation kickoff (initial,
|
|
173
|
+
* retry, /mf-plan-implement, and verifier repair, since all reuse this
|
|
174
|
+
* builder). Phrased to degrade gracefully when no subagent/task tool is
|
|
175
|
+
* available, so no per-call capability flag has to be threaded through.
|
|
176
|
+
*/
|
|
177
|
+
const SUBAGENT_DELEGATION_DIRECTIVE =
|
|
178
|
+
"Where your environment provides a subagent/task tool, delegate independent, well-scoped pieces of this plan — codebase exploration, edits confined to disjoint files, running a test suite — launching independent ones in parallel; keep ownership of integration and of any file two steps touch, and never rely on a delegated result you have not read back. If no such tool is available, proceed directly.";
|
|
179
|
+
|
|
180
|
+
export function buildImplementationKickoffMessage(
|
|
181
|
+
plan: string,
|
|
182
|
+
planFilePath: string,
|
|
183
|
+
note?: string,
|
|
184
|
+
): string {
|
|
185
|
+
const noteParagraph = note ? `${note}\n\n` : "";
|
|
186
|
+
return `${noteParagraph}User has approved your plan. You can now start coding. Start with updating your todo list if applicable.
|
|
187
|
+
|
|
188
|
+
${SUBAGENT_DELEGATION_DIRECTIVE}
|
|
189
|
+
|
|
190
|
+
Your plan has been saved to: ${planFilePath}
|
|
191
|
+
You can refer back to it if needed during implementation.
|
|
192
|
+
|
|
193
|
+
## Approved Plan:
|
|
194
|
+
${plan}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function runImplementationRetryFlow(
|
|
198
|
+
ctx: ExtensionContext,
|
|
199
|
+
host: MoaRunHost,
|
|
200
|
+
handoff: ImplementationHandoff,
|
|
201
|
+
failureReason?: string,
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
const plan = resolveHandoffPlan(handoff, ctx.cwd);
|
|
204
|
+
if (!plan) {
|
|
205
|
+
host.noteRunError(ctx, "Implementation failed and the approved plan content could not be retrieved.");
|
|
206
|
+
ctx.ui.notify("Implementation failed and approved plan content could not be retrieved from disk.", "error");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!ctx.hasUI) {
|
|
211
|
+
host.noteRunError(ctx, "Implementation failed with no interactive retry available.");
|
|
212
|
+
const reasonStr = failureReason ? `: ${failureReason}` : "";
|
|
213
|
+
ctx.ui.notify(`Implementation failed${reasonStr}. Run /mf-plan-implement to retry with the same or a different model.`, "error");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const currentLabel = ctx.model ? modelRefLabel(ctx.model) : "current model";
|
|
218
|
+
const options = [
|
|
219
|
+
`Retry with ${currentLabel}`,
|
|
220
|
+
"Choose a different model",
|
|
221
|
+
"Continue manually",
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const selection = await ctx.ui.select(
|
|
225
|
+
"Implementation failed — the plan is still available. What next?",
|
|
226
|
+
options,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (!selection || selection === "Continue manually") {
|
|
230
|
+
host.stopActiveProgressWidget();
|
|
231
|
+
ctx.ui.notify(`Implementation paused. The approved plan is saved at ${handoff.planFilePath}. Run /mf-plan-implement anytime to resume.`, "info");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (selection.startsWith("Retry with")) {
|
|
236
|
+
host.markImplementationPending(ctx);
|
|
237
|
+
if (handoff.model) host.getActiveProgressWidget()?.switchToImplementing(handoff.model, "retrying implementation…", handoff.thinking);
|
|
238
|
+
const note = failureReason
|
|
239
|
+
? `The previous implementation attempt failed with: ${failureReason}. Review the current state of the code and continue implementing the approved plan.`
|
|
240
|
+
: "The previous implementation attempt failed. Review the current state of the code and continue implementing the approved plan.";
|
|
241
|
+
await host.pi.sendUserMessage(
|
|
242
|
+
buildImplementationKickoffMessage(plan, handoff.planFilePath, note),
|
|
243
|
+
TRIGGER_TURN,
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (selection === "Choose a different model") {
|
|
249
|
+
const newSelection = await showImplementingModelPicker(
|
|
250
|
+
ctx,
|
|
251
|
+
host.currentThinkingLevel(),
|
|
252
|
+
"Implementation failed — choose a different model",
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (!newSelection) {
|
|
256
|
+
// User cancelled model picker; re-run retry flow
|
|
257
|
+
return runImplementationRetryFlow(ctx, host, handoff, failureReason);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await host.applyImplementingSelection(
|
|
261
|
+
ctx,
|
|
262
|
+
newSelection,
|
|
263
|
+
`Switched to ${modelRefLabel(newSelection.ref)} for implementation.`,
|
|
264
|
+
);
|
|
265
|
+
const updatedHandoff: ImplementationHandoff = {
|
|
266
|
+
...handoff,
|
|
267
|
+
model: newSelection.ref,
|
|
268
|
+
thinking: newSelection.thinking,
|
|
269
|
+
timestamp: Date.now(),
|
|
270
|
+
};
|
|
271
|
+
host.setImplementationHandoff(updatedHandoff);
|
|
272
|
+
host.markImplementationPending(ctx);
|
|
273
|
+
|
|
274
|
+
const widget = host.getActiveProgressWidget();
|
|
275
|
+
if (widget) {
|
|
276
|
+
const moaInfo = host.getActiveRunMoaInfo();
|
|
277
|
+
widget.setPhaseModels({
|
|
278
|
+
Plan: moaInfo?.proposers,
|
|
279
|
+
Synthesize: moaInfo?.synthesizer,
|
|
280
|
+
Implement: newSelection.ref,
|
|
281
|
+
Verify: updatedHandoff.verifier,
|
|
282
|
+
});
|
|
283
|
+
widget.switchToImplementing(newSelection.ref, "retrying implementation…", newSelection.thinking);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const note = `Switched implementation model to ${modelRefLabel(newSelection.ref)}. Review the current state of the code and continue implementing the approved plan.`;
|
|
287
|
+
await host.pi.sendUserMessage(
|
|
288
|
+
buildImplementationKickoffMessage(plan, handoff.planFilePath, note),
|
|
289
|
+
TRIGGER_TURN,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { calculateCost } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
|
|
8
|
+
import type { UsageStats } from "../runtime/results.ts";
|
|
9
|
+
import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
10
|
+
|
|
11
|
+
export function modelUsesExtensionRegisteredProvider(ctx: ExtensionContext, ref: ModelRef): boolean {
|
|
12
|
+
return ctx.modelRegistry.getRegisteredProviderIds().includes(ref.provider);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function cursorBridgeExtensionPath(): string | undefined {
|
|
16
|
+
const packageDir = path.join(getAgentDir(), "extensions", "cursor-bridge");
|
|
17
|
+
try {
|
|
18
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf-8")) as {
|
|
19
|
+
pi?: { extensions?: unknown };
|
|
20
|
+
};
|
|
21
|
+
const entries = packageJson.pi?.extensions;
|
|
22
|
+
if (!Array.isArray(entries)) return undefined;
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (typeof entry !== "string") continue;
|
|
25
|
+
const entryPath = path.resolve(packageDir, entry);
|
|
26
|
+
if (fs.existsSync(entryPath)) return entryPath;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Fall back to normal extension discovery below when cursor-bridge is
|
|
30
|
+
// installed outside the conventional global-extension package layout.
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function modelExtensionOptions(ctx: ExtensionContext, ref: ModelRef): { loadExtensions?: boolean; extensionPath?: string } {
|
|
36
|
+
if (ref.provider === "cursor-bridge") {
|
|
37
|
+
const extensionPath = cursorBridgeExtensionPath();
|
|
38
|
+
if (extensionPath) return { extensionPath };
|
|
39
|
+
}
|
|
40
|
+
return modelUsesExtensionRegisteredProvider(ctx, ref) ? { loadExtensions: true } : {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveContextWindow(ctx: ExtensionContext, ref: ModelRef): number | undefined {
|
|
44
|
+
return ctx.modelRegistry.find(ref.provider, ref.id)?.contextWindow;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Calculate cumulative cost from the registry rates and the runner's token totals. */
|
|
48
|
+
export function resolveModelCost(ctx: ExtensionContext, ref: ModelRef, usage: UsageStats): number | undefined {
|
|
49
|
+
const model = ctx.modelRegistry.find(ref.provider, ref.id);
|
|
50
|
+
if (!model) return undefined;
|
|
51
|
+
const cost = calculateCost(model, {
|
|
52
|
+
input: usage.input,
|
|
53
|
+
output: usage.output,
|
|
54
|
+
cacheRead: usage.cacheRead,
|
|
55
|
+
cacheWrite: usage.cacheWrite,
|
|
56
|
+
cacheWrite1h: usage.cacheWrite1h,
|
|
57
|
+
totalTokens: usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
|
|
58
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
59
|
+
});
|
|
60
|
+
return Number.isFinite(cost.total) && cost.total >= 0 ? cost.total : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function applyImplementingSelection(
|
|
64
|
+
pi: ExtensionAPI,
|
|
65
|
+
ctx: ExtensionContext,
|
|
66
|
+
selection: { ref: ModelRef; thinking: ThinkingLevel },
|
|
67
|
+
successNotice: string,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const model = ctx.modelRegistry.find(selection.ref.provider, selection.ref.id);
|
|
70
|
+
if (!model) {
|
|
71
|
+
ctx.ui.notify(`Model ${modelRefLabel(selection.ref)} not in registry; keeping current model.`, "warning");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const ok = await pi.setModel(model);
|
|
75
|
+
if (!ok) {
|
|
76
|
+
ctx.ui.notify(`No API key for ${modelRefLabel(selection.ref)}; keeping current model.`, "warning");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const settings = loadMoaConfig();
|
|
80
|
+
saveMoaConfig({
|
|
81
|
+
...settings,
|
|
82
|
+
implementer: selection.ref,
|
|
83
|
+
thinkingOverrides: { ...settings.thinkingOverrides, [modelRefLabel(selection.ref)]: selection.thinking },
|
|
84
|
+
});
|
|
85
|
+
pi.setThinkingLevel(selection.thinking);
|
|
86
|
+
ctx.ui.notify(successNotice);
|
|
87
|
+
}
|