Merge pull request #523 from RooVetGit/allow_architect_mode_to_write_markdown

Allow architect and ask modes to write md files
This commit is contained in:
Matt Rubens
2025-01-24 12:50:08 -05:00
committed by GitHub
12 changed files with 577 additions and 52 deletions

View File

@@ -0,0 +1,188 @@
import { isToolAllowedForMode, FileRestrictionError, ModeConfig } from "../modes"
describe("isToolAllowedForMode", () => {
const customModes: ModeConfig[] = [
{
slug: "markdown-editor",
name: "Markdown Editor",
roleDefinition: "You are a markdown editor",
groups: ["read", ["edit", { fileRegex: "\\.md$" }], "browser"],
},
{
slug: "css-editor",
name: "CSS Editor",
roleDefinition: "You are a CSS editor",
groups: ["read", ["edit", { fileRegex: "\\.css$" }], "browser"],
},
]
it("allows always available tools", () => {
expect(isToolAllowedForMode("ask_followup_question", "markdown-editor", customModes)).toBe(true)
expect(isToolAllowedForMode("attempt_completion", "markdown-editor", customModes)).toBe(true)
})
it("allows unrestricted tools", () => {
expect(isToolAllowedForMode("read_file", "markdown-editor", customModes)).toBe(true)
expect(isToolAllowedForMode("browser_action", "markdown-editor", customModes)).toBe(true)
})
describe("file restrictions", () => {
it("allows editing matching files", () => {
// Test markdown editor mode
const mdResult = isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, {
path: "test.md",
})
expect(mdResult).toBe(true)
// Test CSS editor mode
const cssResult = isToolAllowedForMode("write_to_file", "css-editor", customModes, undefined, {
path: "styles.css",
})
expect(cssResult).toBe(true)
})
it("rejects editing non-matching files", () => {
// Test markdown editor mode with non-markdown file
expect(() =>
isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, { path: "test.js" }),
).toThrow(FileRestrictionError)
expect(() =>
isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, { path: "test.js" }),
).toThrow(/\\.md\$/)
// Test CSS editor mode with non-CSS file
expect(() =>
isToolAllowedForMode("write_to_file", "css-editor", customModes, undefined, { path: "test.js" }),
).toThrow(FileRestrictionError)
expect(() =>
isToolAllowedForMode("write_to_file", "css-editor", customModes, undefined, { path: "test.js" }),
).toThrow(/\\.css\$/)
})
it("applies restrictions to both write_to_file and apply_diff", () => {
// Test write_to_file
const writeResult = isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, {
path: "test.md",
})
expect(writeResult).toBe(true)
// Test apply_diff
const diffResult = isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, {
path: "test.md",
})
expect(diffResult).toBe(true)
// Test both with non-matching file
expect(() =>
isToolAllowedForMode("write_to_file", "markdown-editor", customModes, undefined, { path: "test.js" }),
).toThrow(FileRestrictionError)
expect(() =>
isToolAllowedForMode("apply_diff", "markdown-editor", customModes, undefined, { path: "test.js" }),
).toThrow(FileRestrictionError)
})
it("uses description in file restriction error for custom modes", () => {
const customModesWithDescription: ModeConfig[] = [
{
slug: "docs-editor",
name: "Documentation Editor",
roleDefinition: "You are a documentation editor",
groups: [
"read",
["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }],
"browser",
],
},
]
// Test write_to_file with non-matching file
expect(() =>
isToolAllowedForMode("write_to_file", "docs-editor", customModesWithDescription, undefined, {
path: "test.js",
}),
).toThrow(FileRestrictionError)
expect(() =>
isToolAllowedForMode("write_to_file", "docs-editor", customModesWithDescription, undefined, {
path: "test.js",
}),
).toThrow(/Documentation files only/)
// Test apply_diff with non-matching file
expect(() =>
isToolAllowedForMode("apply_diff", "docs-editor", customModesWithDescription, undefined, {
path: "test.js",
}),
).toThrow(FileRestrictionError)
expect(() =>
isToolAllowedForMode("apply_diff", "docs-editor", customModesWithDescription, undefined, {
path: "test.js",
}),
).toThrow(/Documentation files only/)
// Test that matching files are allowed
expect(
isToolAllowedForMode("write_to_file", "docs-editor", customModesWithDescription, undefined, {
path: "test.md",
}),
).toBe(true)
expect(
isToolAllowedForMode("write_to_file", "docs-editor", customModesWithDescription, undefined, {
path: "test.txt",
}),
).toBe(true)
})
it("allows ask mode to edit markdown files only", () => {
// Should allow editing markdown files
expect(isToolAllowedForMode("write_to_file", "ask", [], undefined, { path: "test.md" })).toBe(true)
// Should allow applying diffs to markdown files
expect(isToolAllowedForMode("apply_diff", "ask", [], undefined, { path: "readme.md" })).toBe(true)
// Should reject non-markdown files
expect(() => isToolAllowedForMode("write_to_file", "ask", [], undefined, { path: "test.js" })).toThrow(
FileRestrictionError,
)
expect(() => isToolAllowedForMode("write_to_file", "ask", [], undefined, { path: "test.js" })).toThrow(
/Markdown files only/,
)
// Should maintain read capabilities
expect(isToolAllowedForMode("read_file", "ask", [])).toBe(true)
expect(isToolAllowedForMode("browser_action", "ask", [])).toBe(true)
expect(isToolAllowedForMode("use_mcp_tool", "ask", [])).toBe(true)
})
})
it("handles non-existent modes", () => {
expect(isToolAllowedForMode("write_to_file", "non-existent", customModes)).toBe(false)
})
it("respects tool requirements", () => {
const toolRequirements = {
write_to_file: false,
}
expect(isToolAllowedForMode("write_to_file", "markdown-editor", customModes, toolRequirements)).toBe(false)
})
})
describe("FileRestrictionError", () => {
it("formats error message with pattern when no description provided", () => {
const error = new FileRestrictionError("Markdown Editor", "\\.md$", undefined, "test.js")
expect(error.message).toBe(
"This mode (Markdown Editor) can only edit files matching pattern: \\.md$. Got: test.js",
)
expect(error.name).toBe("FileRestrictionError")
})
it("formats error message with description when provided", () => {
const error = new FileRestrictionError("Markdown Editor", "\\.md$", "Markdown files only", "test.js")
expect(error.message).toBe(
"This mode (Markdown Editor) can only edit files matching pattern: \\.md$ (Markdown files only). Got: test.js",
)
expect(error.name).toBe("FileRestrictionError")
})
})

View File

@@ -3,13 +3,22 @@ import { TOOL_GROUPS, ToolGroup, ALWAYS_AVAILABLE_TOOLS } from "./tool-groups"
// Mode types
export type Mode = string
// Group options type
export type GroupOptions = {
fileRegex?: string // Regular expression pattern
description?: string // Human-readable description of the pattern
}
// Group entry can be either a string or tuple with options
export type GroupEntry = ToolGroup | readonly [ToolGroup, GroupOptions]
// Mode configuration type
export type ModeConfig = {
slug: string
name: string
roleDefinition: string
customInstructions?: string
groups: readonly ToolGroup[] // Now uses groups instead of tools array
groups: readonly GroupEntry[] // Now supports both simple strings and tuples with options
}
// Mode-specific prompts only
@@ -22,13 +31,35 @@ export type CustomModePrompts = {
[key: string]: PromptComponent | undefined
}
// Helper to extract group name regardless of format
export function getGroupName(group: GroupEntry): ToolGroup {
return Array.isArray(group) ? group[0] : group
}
// Helper to get group options if they exist
function getGroupOptions(group: GroupEntry): GroupOptions | undefined {
return Array.isArray(group) ? group[1] : undefined
}
// Helper to check if a file path matches a regex pattern
export function doesFileMatchRegex(filePath: string, pattern: string): boolean {
try {
const regex = new RegExp(pattern)
return regex.test(filePath)
} catch (error) {
console.error(`Invalid regex pattern: ${pattern}`, error)
return false
}
}
// Helper to get all tools for a mode
export function getToolsForMode(groups: readonly ToolGroup[]): string[] {
export function getToolsForMode(groups: readonly GroupEntry[]): string[] {
const tools = new Set<string>()
// Add tools from each group
groups.forEach((group) => {
TOOL_GROUPS[group].forEach((tool) => tools.add(tool))
const groupName = getGroupName(group)
TOOL_GROUPS[groupName].forEach((tool) => tools.add(tool))
})
// Always add required tools
@@ -50,15 +81,15 @@ export const modes: readonly ModeConfig[] = [
slug: "architect",
name: "Architect",
roleDefinition:
"You are Roo, a software architecture expert specializing in analyzing codebases, identifying patterns, and providing high-level technical guidance. You excel at understanding complex systems, evaluating architectural decisions, and suggesting improvements while maintaining a read-only approach to the codebase. Make sure to help the user come up with a solid implementation plan for their project and don't rush to switch to implementing code.",
groups: ["read", "browser", "mcp"],
"You are Roo, a software architecture expert specializing in analyzing codebases, identifying patterns, and providing high-level technical guidance. You excel at understanding complex systems, evaluating architectural decisions, and suggesting improvements. You can edit markdown documentation files to help document architectural decisions and patterns.",
groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "browser", "mcp"],
},
{
slug: "ask",
name: "Ask",
roleDefinition:
"You are Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. You can analyze code, explain concepts, and access external resources while maintaining a read-only approach to the codebase. Make sure to answer the user's questions and don't rush to switch to implementing code.",
groups: ["read", "browser", "mcp"],
"You are Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. You can analyze code, explain concepts, and access external resources. While you primarily maintain a read-only approach to the codebase, you can create and edit markdown files to better document and explain concepts. Make sure to answer the user's questions and don't rush to switch to implementing code.",
groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "browser", "mcp"],
},
] as const
@@ -113,11 +144,22 @@ export function isCustomMode(slug: string, customModes?: ModeConfig[]): boolean
return !!customModes?.some((mode) => mode.slug === slug)
}
// Custom error class for file restrictions
export class FileRestrictionError extends Error {
constructor(mode: string, pattern: string, description: string | undefined, filePath: string) {
super(
`This mode (${mode}) can only edit files matching pattern: ${pattern}${description ? ` (${description})` : ""}. Got: ${filePath}`,
)
this.name = "FileRestrictionError"
}
}
export function isToolAllowedForMode(
tool: string,
modeSlug: string,
customModes: ModeConfig[],
toolRequirements?: Record<string, boolean>,
toolParams?: Record<string, any>, // All tool parameters
): boolean {
// Always allow these tools
if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) {
@@ -136,8 +178,35 @@ export function isToolAllowedForMode(
return false
}
// Check if tool is in any of the mode's groups
return mode.groups.some((group) => TOOL_GROUPS[group].includes(tool as string))
// Check if tool is in any of the mode's groups and respects any group options
for (const group of mode.groups) {
const groupName = getGroupName(group)
const options = getGroupOptions(group)
// If the tool isn't in this group, continue to next group
if (!TOOL_GROUPS[groupName].includes(tool)) {
continue
}
// If there are no options, allow the tool
if (!options) {
return true
}
// For the edit group, check file regex if specified
if (groupName === "edit" && options.fileRegex) {
const filePath = toolParams?.path
// Only validate regex if a path is provided
if (filePath && !doesFileMatchRegex(filePath, options.fileRegex)) {
throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath)
}
return true
}
return true
}
return false
}
// Create the mode-specific default prompts