Merge pull request #1249 from RooVetGit/custom_system_prompt

Allow users to set custom system prompts
This commit is contained in:
Matt Rubens 2025-02-27 15:57:16 -05:00 committed by GitHub
commit 3d0e054c56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 317 additions and 1 deletions

View file

@ -140,7 +140,6 @@ const mockFs = {
currentPath += "/" + parts[parts.length - 1]
mockDirectories.add(currentPath)
return Promise.resolve()
return Promise.resolve()
}),
access: jest.fn().mockImplementation(async (path: string) => {

View file

@ -15,3 +15,33 @@ jest.mock("../utils/logging", () => ({
}),
},
}))
// Add toPosix method to String prototype for all tests, mimicking src/utils/path.ts
// This is needed because the production code expects strings to have this method
// Note: In production, this is added via import in the entry point (extension.ts)
export {}
declare global {
interface String {
toPosix(): string
}
}
// Implementation that matches src/utils/path.ts
function toPosixPath(p: string) {
// Extended-Length Paths in Windows start with "\\?\" to allow longer paths
// and bypass usual parsing. If detected, we return the path unmodified.
const isExtendedLengthPath = p.startsWith("\\\\?\\")
if (isExtendedLengthPath) {
return p
}
return p.replace(/\\/g, "/")
}
if (!String.prototype.toPosix) {
String.prototype.toPosix = function (this: string): string {
return toPosixPath(this)
}
}

View file

@ -0,0 +1,172 @@
import { SYSTEM_PROMPT } from "../system"
import { defaultModeSlug, modes } from "../../../shared/modes"
import * as vscode from "vscode"
import * as fs from "fs/promises"
// Mock the fs/promises module
jest.mock("fs/promises", () => ({
readFile: jest.fn(),
mkdir: jest.fn().mockResolvedValue(undefined),
access: jest.fn().mockResolvedValue(undefined),
}))
// Get the mocked fs module
const mockedFs = fs as jest.Mocked<typeof fs>
// Mock the fileExistsAtPath function
jest.mock("../../../utils/fs", () => ({
fileExistsAtPath: jest.fn().mockResolvedValue(true),
createDirectoriesForFile: jest.fn().mockResolvedValue([]),
}))
// Create a mock ExtensionContext with relative paths instead of absolute paths
const mockContext = {
extensionPath: "mock/extension/path",
globalStoragePath: "mock/storage/path",
storagePath: "mock/storage/path",
logPath: "mock/log/path",
subscriptions: [],
workspaceState: {
get: () => undefined,
update: () => Promise.resolve(),
},
globalState: {
get: () => undefined,
update: () => Promise.resolve(),
setKeysForSync: () => {},
},
extensionUri: { fsPath: "mock/extension/path" },
globalStorageUri: { fsPath: "mock/settings/path" },
asAbsolutePath: (relativePath: string) => `mock/extension/path/${relativePath}`,
extension: {
packageJSON: {
version: "1.0.0",
},
},
} as unknown as vscode.ExtensionContext
describe("File-Based Custom System Prompt", () => {
const experiments = {}
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks()
// Default behavior: file doesn't exist
mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
})
it("should use default generation when no file-based system prompt is found", async () => {
const customModePrompts = {
[defaultModeSlug]: {
roleDefinition: "Test role definition",
},
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"test/path", // Using a relative path without leading slash
false,
undefined,
undefined,
undefined,
defaultModeSlug,
customModePrompts,
undefined,
undefined,
undefined,
undefined,
experiments,
true,
)
// Should contain default sections
expect(prompt).toContain("TOOL USE")
expect(prompt).toContain("CAPABILITIES")
expect(prompt).toContain("MODES")
expect(prompt).toContain("Test role definition")
})
it("should use file-based custom system prompt when available", async () => {
// Mock the readFile to return content from a file
const fileCustomSystemPrompt = "Custom system prompt from file"
// When called with utf-8 encoding, return a string
mockedFs.readFile.mockImplementation((filePath, options) => {
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
return Promise.resolve(fileCustomSystemPrompt)
}
return Promise.reject({ code: "ENOENT" })
})
const prompt = await SYSTEM_PROMPT(
mockContext,
"test/path", // Using a relative path without leading slash
false,
undefined,
undefined,
undefined,
defaultModeSlug,
undefined,
undefined,
undefined,
undefined,
undefined,
experiments,
true,
)
// Should contain role definition and file-based system prompt
expect(prompt).toContain(modes[0].roleDefinition)
expect(prompt).toContain(fileCustomSystemPrompt)
// Should not contain any of the default sections
expect(prompt).not.toContain("TOOL USE")
expect(prompt).not.toContain("CAPABILITIES")
expect(prompt).not.toContain("MODES")
})
it("should combine file-based system prompt with role definition and custom instructions", async () => {
// Mock the readFile to return content from a file
const fileCustomSystemPrompt = "Custom system prompt from file"
mockedFs.readFile.mockImplementation((filePath, options) => {
if (filePath.toString().includes(`.roo/system-prompt-${defaultModeSlug}`) && options === "utf-8") {
return Promise.resolve(fileCustomSystemPrompt)
}
return Promise.reject({ code: "ENOENT" })
})
// Define custom role definition
const customRoleDefinition = "Custom role definition"
const customModePrompts = {
[defaultModeSlug]: {
roleDefinition: customRoleDefinition,
},
}
const prompt = await SYSTEM_PROMPT(
mockContext,
"test/path", // Using a relative path without leading slash
false,
undefined,
undefined,
undefined,
defaultModeSlug,
customModePrompts,
undefined,
undefined,
undefined,
undefined,
experiments,
true,
)
// Should contain custom role definition and file-based system prompt
expect(prompt).toContain(customRoleDefinition)
expect(prompt).toContain(fileCustomSystemPrompt)
// Should not contain any of the default sections
expect(prompt).not.toContain("TOOL USE")
expect(prompt).not.toContain("CAPABILITIES")
expect(prompt).not.toContain("MODES")
})
})

View file

@ -0,0 +1,60 @@
import fs from "fs/promises"
import path from "path"
import { Mode } from "../../../shared/modes"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Safely reads a file, returning an empty string if the file doesn't exist
*/
async function safeReadFile(filePath: string): Promise<string> {
try {
const content = await fs.readFile(filePath, "utf-8")
// When reading with "utf-8" encoding, content should be a string
return content.trim()
} catch (err) {
const errorCode = (err as NodeJS.ErrnoException).code
if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
throw err
}
return ""
}
}
/**
* Get the path to a system prompt file for a specific mode
*/
export function getSystemPromptFilePath(cwd: string, mode: Mode): string {
return path.join(cwd, ".roo", `system-prompt-${mode}`)
}
/**
* Loads custom system prompt from a file at .roo/system-prompt-[mode slug]
* If the file doesn't exist, returns an empty string
*/
export async function loadSystemPromptFile(cwd: string, mode: Mode): Promise<string> {
const filePath = getSystemPromptFilePath(cwd, mode)
return safeReadFile(filePath)
}
/**
* Ensures the .roo directory exists, creating it if necessary
*/
export async function ensureRooDirectory(cwd: string): Promise<void> {
const rooDir = path.join(cwd, ".roo")
// Check if directory already exists
if (await fileExistsAtPath(rooDir)) {
return
}
// Create the directory
try {
await fs.mkdir(rooDir, { recursive: true })
} catch (err) {
// If directory already exists (race condition), ignore the error
const errorCode = (err as NodeJS.ErrnoException).code
if (errorCode !== "EEXIST") {
throw err
}
}
}

View file

@ -23,6 +23,7 @@ import {
getModesSection,
addCustomInstructions,
} from "./sections"
import { loadSystemPromptFile } from "./sections/custom-system-prompt"
import fs from "fs/promises"
import path from "path"
@ -119,11 +120,25 @@ export const SYSTEM_PROMPT = async (
return undefined
}
// Try to load custom system prompt from file
const fileCustomSystemPrompt = await loadSystemPromptFile(cwd, mode)
// Check if it's a custom mode
const promptComponent = getPromptComponent(customModePrompts?.[mode])
// Get full mode config from custom modes or fall back to built-in modes
const currentMode = getModeBySlug(mode, customModes) || modes.find((m) => m.slug === mode) || modes[0]
// If a file-based custom system prompt exists, use it
if (fileCustomSystemPrompt) {
const roleDefinition = promptComponent?.roleDefinition || currentMode.roleDefinition
return `${roleDefinition}
${fileCustomSystemPrompt}
${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
}
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined

View file

@ -88,6 +88,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
const [showConfigMenu, setShowConfigMenu] = useState(false)
const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false)
// Direct update functions
const updateAgentPrompt = useCallback(
@ -971,6 +972,45 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
<span className="codicon codicon-copy"></span>
</VSCodeButton>
</div>
{/* Custom System Prompt Disclosure */}
<div className="mb-3 mt-12">
<button
onClick={() => setIsSystemPromptDisclosureOpen(!isSystemPromptDisclosureOpen)}
className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
aria-expanded={isSystemPromptDisclosureOpen}>
<span
className={`codicon codicon-${isSystemPromptDisclosureOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
<span>Advanced: Override System Prompt</span>
</button>
{isSystemPromptDisclosureOpen && (
<div className="text-xs text-vscode-descriptionForeground mt-2 ml-5">
You can completely replace the system prompt for this mode (aside from the role
definition and custom instructions) by creating a file at{" "}
<span
className="text-vscode-textLink-foreground cursor-pointer underline"
onClick={() => {
const currentMode = getCurrentMode()
if (!currentMode) return
// Open or create an empty file
vscode.postMessage({
type: "openFile",
text: `./.roo/system-prompt-${currentMode.slug}`,
values: {
create: true,
content: "",
},
})
}}>
.roo/system-prompt-{getCurrentMode()?.slug || "code"}
</span>{" "}
in your workspace. This is a very advanced feature that bypasses built-in safeguards and
consistency checks (especially around tool usage), so be careful!
</div>
)}
</div>
</div>
<div