mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge pull request #1249 from RooVetGit/custom_system_prompt
Allow users to set custom system prompts
This commit is contained in:
commit
3d0e054c56
6 changed files with 317 additions and 1 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
172
src/core/prompts/__tests__/custom-system-prompt.test.ts
Normal file
172
src/core/prompts/__tests__/custom-system-prompt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
60
src/core/prompts/sections/custom-system-prompt.ts
Normal file
60
src/core/prompts/sections/custom-system-prompt.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue