From ae878c376f54725723c8779d7f076ef33bc12710 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 16 Nov 2025 21:43:00 +0000 Subject: [PATCH 1/4] feat: add model-specific instructions --- docs/instruction-files.md | 26 +++++++++ src/node/services/aiService.ts | 3 +- src/node/services/systemMessage.test.ts | 69 ++++++++++++++++++++++++ src/node/services/systemMessage.ts | 46 ++++++++++++++-- src/node/utils/main/markdown.ts | 72 +++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 6 deletions(-) diff --git a/docs/instruction-files.md b/docs/instruction-files.md index 6fb04d703..32da8c89e 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -62,6 +62,32 @@ When compacting conversation history: Customizing the `compact` mode is particularly useful for controlling what information is preserved during automatic history compaction. +## Model Prompts + +Similar to modes, mux reads headings titled `Model: ` to scope instructions to specific models or families. The `` is matched against the full model identifier (for example, `openai:gpt-5.1-codex`). + +Rules: + +- Workspace instructions are evaluated before global instructions; the first matching section wins. +- Regexes are case-insensitive by default. Use `/pattern/flags` syntax to opt into custom flags (e.g., `/openai:.*codex/i`). +- Invalid regex patterns are ignored instead of breaking the parse. +- Only the content under the first matching heading is injected. + + + +Example: + +```markdown +## Model: sonnet +Anthropic's Claude Sonnet family tends to wax poetic—answer in two sentences max and focus on code changes. + +## Model: /openai:.*codex/i +OpenAI's GPT-5.1 Codex models already respond tersely, so no additional instruction is required. +``` + +The second section documents that OpenAI models (as of `openai:gpt-5.1-codex`) don't need extra prompting, while Sonnet benefits from an explicit "be terse" reminder. + + ## Practical layout ``` diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 766d00a13..bb661c15b 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -655,7 +655,8 @@ export class AIService extends EventEmitter { runtime, workspacePath, mode, - additionalSystemInstructions + additionalSystemInstructions, + modelString ); // Count system message tokens for cost tracking diff --git a/src/node/services/systemMessage.test.ts b/src/node/services/systemMessage.test.ts index b6eb051e1..855ebaa8a 100644 --- a/src/node/services/systemMessage.test.ts +++ b/src/node/services/systemMessage.test.ts @@ -203,4 +203,73 @@ Special mode instructions. expect(systemMessage).toContain("Special mode instructions"); expect(systemMessage).toContain(""); }); + + test("includes model-specific section when regex matches active model", async () => { + await fs.writeFile( + path.join(projectDir, "AGENTS.md"), + `# Instructions +## Model: sonnet +Respond to Sonnet tickets in two sentences max. +` + ); + + const metadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "test-project", + projectPath: projectDir, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }; + + const systemMessage = await buildSystemMessage( + metadata, + runtime, + workspaceDir, + undefined, + undefined, + "anthropic:claude-3.5-sonnet" + ); + + expect(systemMessage).toContain(""); + expect(systemMessage).toContain("Respond to Sonnet tickets in two sentences max."); + expect(systemMessage).toContain(""); + }); + + test("falls back to global model section when project lacks a match", async () => { + await fs.writeFile( + path.join(globalDir, "AGENTS.md"), + `# Global Instructions +## Model: /openai:.*codex/i +OpenAI's GPT-5.1 Codex models already default to terse replies. +` + ); + + await fs.writeFile( + path.join(projectDir, "AGENTS.md"), + `# Project Instructions +General details only. +` + ); + + const metadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "test-project", + projectPath: projectDir, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }; + + const systemMessage = await buildSystemMessage( + metadata, + runtime, + workspaceDir, + undefined, + undefined, + "openai:gpt-5.1-codex" + ); + + expect(systemMessage).toContain(""); + expect(systemMessage).toContain("OpenAI's GPT-5.1 Codex models already default to terse replies."); + }); + }); diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index fbf35a60f..17f393a83 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -3,7 +3,7 @@ import { readInstructionSet, readInstructionSetFromRuntime, } from "@/node/utils/main/instructionFiles"; -import { extractModeSection } from "@/node/utils/main/markdown"; +import { extractModeSection, extractModelSection } from "@/node/utils/main/markdown"; import type { Runtime } from "@/node/runtime/Runtime"; import { getMuxHome } from "@/common/constants/paths"; @@ -12,6 +12,24 @@ import { getMuxHome } from "@/common/constants/paths"; // The PRELUDE is intentionally minimal to not conflict with the user's instructions. // mux is designed to be model agnostic, and models have shown large inconsistency in how they // follow instructions. + +function sanitizeSectionTag(value: string | undefined, fallback: string): string { + const normalized = (value ?? "") + .toLowerCase() + .replace(/[^a-z0-9_-]/gi, "-") + .replace(/-+/g, "-"); + return normalized.length > 0 ? normalized : fallback; +} + +function buildTaggedSection( + content: string | null, + rawTagValue: string | undefined, + fallback: string +): string { + if (!content) return ""; + const tag = sanitizeSectionTag(rawTagValue, fallback); + return `\n\n<${tag}>\n${content}\n`; +} const PRELUDE = ` You are a coding agent. @@ -71,6 +89,7 @@ function getSystemDirectory(): string { * @param workspacePath - Workspace directory path * @param mode - Optional mode name (e.g., "plan", "exec") * @param additionalSystemInstructions - Optional instructions appended last + * @param modelString - Active model identifier used for Model-specific sections * @throws Error if metadata or workspacePath invalid */ export async function buildSystemMessage( @@ -78,7 +97,8 @@ export async function buildSystemMessage( runtime: Runtime, workspacePath: string, mode?: string, - additionalSystemInstructions?: string + additionalSystemInstructions?: string, + modelString?: string ): Promise { if (!metadata) throw new Error("Invalid workspace metadata: metadata is required"); if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required"); @@ -101,6 +121,15 @@ export async function buildSystemMessage( null; } + // Extract model-specific section based on active model identifier (context first) + let modelContent: string | null = null; + if (modelString) { + modelContent = + (contextInstructions && extractModelSection(contextInstructions, modelString)) ?? + (globalInstructions && extractModelSection(globalInstructions, modelString)) ?? + null; + } + // Build system message let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(workspacePath)}`; @@ -108,9 +137,16 @@ export async function buildSystemMessage( systemMessage += `\n\n${customInstructions}\n`; } - if (modeContent) { - const tag = (mode ?? "mode").toLowerCase().replace(/[^a-z0-9_-]/gi, "-"); - systemMessage += `\n\n<${tag}>\n${modeContent}\n`; + const modeSection = buildTaggedSection(modeContent, mode, "mode"); + if (modeSection) { + systemMessage += modeSection; + } + + if (modelContent && modelString) { + const modelSection = buildTaggedSection(modelContent, `model-${modelString}`, "model"); + if (modelSection) { + systemMessage += modelSection; + } } if (additionalSystemInstructions) { diff --git a/src/node/utils/main/markdown.ts b/src/node/utils/main/markdown.ts index 3742ba056..48cd83727 100644 --- a/src/node/utils/main/markdown.ts +++ b/src/node/utils/main/markdown.ts @@ -49,3 +49,75 @@ export function extractModeSection(markdown: string, mode: string): string | nul return null; } + +/** + * Extract the first section whose heading matches "Model: " and whose regex matches + * the provided model identifier. Matching is case-insensitive by default unless the regex + * heading explicitly specifies flags via /pattern/flags syntax. + */ +export function extractModelSection(markdown: string, modelId: string): string | null { + if (!markdown || !modelId) return null; + + const md = new MarkdownIt({ html: false, linkify: false, typographer: false }); + const tokens = md.parse(markdown, {}); + const lines = markdown.split(/\r?\n/); + const headingPattern = /^model:\s*(.+)$/i; + + const compileRegex = (pattern: string): RegExp | null => { + const trimmed = pattern.trim(); + if (!trimmed) return null; + + // Allow optional /pattern/flags syntax; default to case-insensitive matching otherwise + if (trimmed.startsWith("/") && trimmed.lastIndexOf("/") > 0) { + const lastSlash = trimmed.lastIndexOf("/"); + const source = trimmed.slice(1, lastSlash); + const flags = trimmed.slice(lastSlash + 1); + try { + return new RegExp(source, flags || undefined); + } catch { + return null; + } + } + + try { + return new RegExp(trimmed, "i"); + } catch { + return null; + } + }; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.type !== "heading_open") continue; + + const level = Number(token.tag?.replace(/^h/, "")) || 1; + const inline = tokens[i + 1]; + if (inline?.type !== "inline") continue; + + const match = headingPattern.exec((inline.content || "").trim()); + if (!match) continue; + + const regex = compileRegex(match[1] ?? ""); + if (!regex) continue; + if (!regex.test(modelId)) continue; + + const headingEndLine = inline.map?.[1] ?? token.map?.[1] ?? (token.map?.[0] ?? 0) + 1; + + let endLine = lines.length; + for (let j = i + 1; j < tokens.length; j++) { + const nextToken = tokens[j]; + if (nextToken.type === "heading_open") { + const nextLevel = Number(nextToken.tag?.replace(/^h/, "")) || 1; + if (nextLevel <= level) { + endLine = nextToken.map?.[0] ?? endLine; + break; + } + } + } + + const slice = lines.slice(headingEndLine, endLine).join("\n").trim(); + return slice.length > 0 ? slice : null; + } + + return null; +} From 91f8d5e2ad9fa92dd6340e71bb08b2a7b7c91663 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 16 Nov 2025 21:52:12 +0000 Subject: [PATCH 2/4] feat: add model-specific instructions --- src/node/services/systemMessage.test.ts | 5 +- src/node/utils/main/markdown.ts | 94 +++++++++---------------- 2 files changed, 36 insertions(+), 63 deletions(-) diff --git a/src/node/services/systemMessage.test.ts b/src/node/services/systemMessage.test.ts index 855ebaa8a..869c6a224 100644 --- a/src/node/services/systemMessage.test.ts +++ b/src/node/services/systemMessage.test.ts @@ -269,7 +269,8 @@ General details only. ); expect(systemMessage).toContain(""); - expect(systemMessage).toContain("OpenAI's GPT-5.1 Codex models already default to terse replies."); + expect(systemMessage).toContain( + "OpenAI's GPT-5.1 Codex models already default to terse replies." + ); }); - }); diff --git a/src/node/utils/main/markdown.ts b/src/node/utils/main/markdown.ts index 48cd83727..c29284ed2 100644 --- a/src/node/utils/main/markdown.ts +++ b/src/node/utils/main/markdown.ts @@ -1,43 +1,37 @@ import MarkdownIt from "markdown-it"; -/** - * Extract the content under a heading titled "Mode: " (case-insensitive). - * - Matches any heading level (#..######) - * - Returns raw markdown content between this heading and the next heading - * of the same or higher level in the same document - * - If multiple sections match, the first one wins - * - The heading line itself is excluded from the returned content - */ -export function extractModeSection(markdown: string, mode: string): string | null { - if (!markdown || !mode) return null; +type HeadingMatcher = (headingText: string, level: number) => boolean; + +function extractSectionByHeading( + markdown: string, + headingMatcher: HeadingMatcher +): string | null { + if (!markdown) return null; const md = new MarkdownIt({ html: false, linkify: false, typographer: false }); const tokens = md.parse(markdown, {}); const lines = markdown.split(/\r?\n/); - const target = `mode: ${mode}`.toLowerCase(); for (let i = 0; i < tokens.length; i++) { - const t = tokens[i]; - if (t.type !== "heading_open") continue; + const token = tokens[i]; + if (token.type !== "heading_open") continue; - const level = Number(t.tag?.replace(/^h/, "")) || 1; + const level = Number(token.tag?.replace(/^h/, "")) || 1; const inline = tokens[i + 1]; if (inline?.type !== "inline") continue; - const text = (inline.content || "").trim().toLowerCase(); - if (text !== target) continue; + const headingText = (inline.content || "").trim(); + if (!headingMatcher(headingText, level)) continue; - // Start content after the heading block ends - const headingEndLine = inline.map?.[1] ?? t.map?.[1] ?? (t.map?.[0] ?? 0) + 1; + const headingEndLine = inline.map?.[1] ?? token.map?.[1] ?? (token.map?.[0] ?? 0) + 1; - // Find the next heading of same or higher level to bound the section - let endLine = lines.length; // exclusive + let endLine = lines.length; for (let j = i + 1; j < tokens.length; j++) { - const tt = tokens[j]; - if (tt.type === "heading_open") { - const nextLevel = Number(tt.tag?.replace(/^h/, "")) || 1; + const nextToken = tokens[j]; + if (nextToken.type === "heading_open") { + const nextLevel = Number(nextToken.tag?.replace(/^h/, "")) || 1; if (nextLevel <= level) { - endLine = tt.map?.[0] ?? endLine; + endLine = nextToken.map?.[0] ?? endLine; break; } } @@ -50,6 +44,16 @@ export function extractModeSection(markdown: string, mode: string): string | nul return null; } +/** + * Extract the content under a heading titled "Mode: " (case-insensitive). + */ +export function extractModeSection(markdown: string, mode: string): string | null { + if (!markdown || !mode) return null; + + const expectedHeading = `mode: ${mode}`.toLowerCase(); + return extractSectionByHeading(markdown, (headingText) => headingText.toLowerCase() === expectedHeading); +} + /** * Extract the first section whose heading matches "Model: " and whose regex matches * the provided model identifier. Matching is case-insensitive by default unless the regex @@ -58,16 +62,12 @@ export function extractModeSection(markdown: string, mode: string): string | nul export function extractModelSection(markdown: string, modelId: string): string | null { if (!markdown || !modelId) return null; - const md = new MarkdownIt({ html: false, linkify: false, typographer: false }); - const tokens = md.parse(markdown, {}); - const lines = markdown.split(/\r?\n/); const headingPattern = /^model:\s*(.+)$/i; const compileRegex = (pattern: string): RegExp | null => { const trimmed = pattern.trim(); if (!trimmed) return null; - // Allow optional /pattern/flags syntax; default to case-insensitive matching otherwise if (trimmed.startsWith("/") && trimmed.lastIndexOf("/") > 0) { const lastSlash = trimmed.lastIndexOf("/"); const source = trimmed.slice(1, lastSlash); @@ -86,38 +86,10 @@ export function extractModelSection(markdown: string, modelId: string): string | } }; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - if (token.type !== "heading_open") continue; - - const level = Number(token.tag?.replace(/^h/, "")) || 1; - const inline = tokens[i + 1]; - if (inline?.type !== "inline") continue; - - const match = headingPattern.exec((inline.content || "").trim()); - if (!match) continue; - + return extractSectionByHeading(markdown, (headingText) => { + const match = headingPattern.exec(headingText); + if (!match) return false; const regex = compileRegex(match[1] ?? ""); - if (!regex) continue; - if (!regex.test(modelId)) continue; - - const headingEndLine = inline.map?.[1] ?? token.map?.[1] ?? (token.map?.[0] ?? 0) + 1; - - let endLine = lines.length; - for (let j = i + 1; j < tokens.length; j++) { - const nextToken = tokens[j]; - if (nextToken.type === "heading_open") { - const nextLevel = Number(nextToken.tag?.replace(/^h/, "")) || 1; - if (nextLevel <= level) { - endLine = nextToken.map?.[0] ?? endLine; - break; - } - } - } - - const slice = lines.slice(headingEndLine, endLine).join("\n").trim(); - return slice.length > 0 ? slice : null; - } - - return null; + return Boolean(regex && regex.test(modelId)); + }); } From 1b9496bef8c1dec675faa30784e3e971544b318c Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 16 Nov 2025 17:25:55 -0600 Subject: [PATCH 3/4] Update docs --- docs/instruction-files.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/instruction-files.md b/docs/instruction-files.md index 32da8c89e..f89f221bb 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -79,15 +79,12 @@ Example: ```markdown ## Model: sonnet -Anthropic's Claude Sonnet family tends to wax poetic—answer in two sentences max and focus on code changes. +Be terse and to the point. -## Model: /openai:.*codex/i -OpenAI's GPT-5.1 Codex models already respond tersely, so no additional instruction is required. +## Model: openai:.*codex +Use status reporting tools every few minutes. ``` -The second section documents that OpenAI models (as of `openai:gpt-5.1-codex`) don't need extra prompting, while Sonnet benefits from an explicit "be terse" reminder. - - ## Practical layout ``` From 9238822c6a668df51b32f781e0644c5009492322 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 16 Nov 2025 17:27:49 -0600 Subject: [PATCH 4/4] make fmt --- docs/instruction-files.md | 6 +- src/node/services/systemMessage.test.ts | 153 +++++++++++++++++++++++- src/node/services/systemMessage.ts | 20 +++- src/node/utils/main/markdown.ts | 67 +++++++++-- 4 files changed, 228 insertions(+), 18 deletions(-) diff --git a/docs/instruction-files.md b/docs/instruction-files.md index f89f221bb..7f0caf589 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -24,6 +24,7 @@ Rules: - Workspace instructions are checked first, then global instructions - The first matching section wins (at most one section is used) - The section's content is everything until the next heading of the same or higher level +- Mode sections are stripped from the general `` block; only the active mode's content is re-sent via its `` tag. - Missing sections are ignored (no error) @@ -71,6 +72,7 @@ Rules: - Workspace instructions are evaluated before global instructions; the first matching section wins. - Regexes are case-insensitive by default. Use `/pattern/flags` syntax to opt into custom flags (e.g., `/openai:.*codex/i`). - Invalid regex patterns are ignored instead of breaking the parse. +- Model sections are also removed from ``; only the first regex match (if any) is injected via its `` tag. - Only the content under the first matching heading is injected. @@ -79,9 +81,11 @@ Example: ```markdown ## Model: sonnet + Be terse and to the point. -## Model: openai:.*codex +## Model: openai:.\*codex + Use status reporting tools every few minutes. ``` diff --git a/src/node/services/systemMessage.test.ts b/src/node/services/systemMessage.test.ts index 869c6a224..28bb67a7c 100644 --- a/src/node/services/systemMessage.test.ts +++ b/src/node/services/systemMessage.test.ts @@ -4,6 +4,12 @@ import * as path from "path"; import { buildSystemMessage } from "./systemMessage"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; + +const extractTagContent = (message: string, tagName: string): string | null => { + const pattern = new RegExp(`<${tagName}>\\s*([\\s\\S]*?)\\s*`, "i"); + const match = pattern.exec(message); + return match ? match[1].trim() : null; +}; import { describe, test, expect, beforeEach, afterEach, spyOn, type Mock } from "bun:test"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; @@ -63,6 +69,10 @@ Use diagrams where appropriate. const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan"); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + expect(customInstructions).toContain("Always be helpful."); + expect(customInstructions).not.toContain("Focus on planning and design."); + // Should include the mode-specific content expect(systemMessage).toContain(""); expect(systemMessage).toContain("Focus on planning and design"); @@ -99,9 +109,9 @@ Focus on planning and design. expect(systemMessage).not.toContain(""); expect(systemMessage).not.toContain(""); - // All instructions are still in (both general and mode section) - expect(systemMessage).toContain("Always be helpful"); - expect(systemMessage).toContain("Focus on planning and design"); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + expect(customInstructions).toContain("Always be helpful."); + expect(customInstructions).not.toContain("Focus on planning and design."); }); test("prefers project mode section over global mode section", async () => { @@ -230,6 +240,9 @@ Respond to Sonnet tickets in two sentences max. "anthropic:claude-3.5-sonnet" ); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + expect(customInstructions).not.toContain("Respond to Sonnet tickets in two sentences max."); + expect(systemMessage).toContain(""); expect(systemMessage).toContain("Respond to Sonnet tickets in two sentences max."); expect(systemMessage).toContain(""); @@ -268,9 +281,143 @@ General details only. "openai:gpt-5.1-codex" ); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + expect(customInstructions).not.toContain( + "OpenAI's GPT-5.1 Codex models already default to terse replies." + ); + expect(systemMessage).toContain(""); expect(systemMessage).toContain( "OpenAI's GPT-5.1 Codex models already default to terse replies." ); }); + + describe("instruction scoping matrix", () => { + interface Scenario { + name: string; + mdContent: string; + mode?: string; + model?: string; + assert: (message: string) => void; + } + + const scopingScenarios: Scenario[] = [ + { + name: "strips scoped sections when no mode or model provided", + mdContent: `# Notes +General guidance for everyone. + +## Mode: Plan +Plan depth instructions. + +## Model: sonnet +Anthropic-only instructions. +`, + assert: (message) => { + const custom = extractTagContent(message, "custom-instructions") ?? ""; + expect(custom).toContain("General guidance for everyone."); + expect(custom).not.toContain("Plan depth instructions."); + expect(custom).not.toContain("Anthropic-only instructions."); + expect(message).not.toContain("Plan depth instructions."); + expect(message).not.toContain("Anthropic-only instructions."); + }, + }, + { + name: "injects only the requested mode section", + mdContent: `General context for all contributors. + +## Mode: Plan +Plan-only reminders. + +## Mode: Exec +Exec reminders. +`, + mode: "plan", + assert: (message) => { + const custom = extractTagContent(message, "custom-instructions") ?? ""; + expect(custom).toContain("General context for all contributors."); + expect(custom).not.toContain("Plan-only reminders."); + expect(custom).not.toContain("Exec reminders."); + + const planSection = extractTagContent(message, "plan") ?? ""; + expect(planSection).toContain("Plan-only reminders."); + expect(planSection).not.toContain("Exec reminders."); + }, + }, + { + name: "injects only the matching model section", + mdContent: `General base instructions. + +## Model: sonnet +Anthropic-only instructions. + +## Model: /openai:.*/ +OpenAI-only instructions. +`, + model: "openai:gpt-5.1-codex", + assert: (message) => { + const custom = extractTagContent(message, "custom-instructions") ?? ""; + expect(custom).toContain("General base instructions."); + expect(custom).not.toContain("Anthropic-only instructions."); + expect(custom).not.toContain("OpenAI-only instructions."); + + const openaiSection = extractTagContent(message, "model-openai-gpt-5-1-codex") ?? ""; + expect(openaiSection).toContain("OpenAI-only instructions."); + expect(openaiSection).not.toContain("Anthropic-only instructions."); + expect(message).not.toContain("Anthropic-only instructions."); + }, + }, + { + name: "supports simultaneous mode and model scoping", + mdContent: `General instructions for everyone. + +## Mode: Exec +Stay focused on implementation details. + +## Model: sonnet +Answer in two sentences max. +`, + mode: "exec", + model: "anthropic:claude-3.5-sonnet", + assert: (message) => { + const custom = extractTagContent(message, "custom-instructions") ?? ""; + expect(custom).toContain("General instructions for everyone."); + expect(custom).not.toContain("Stay focused on implementation details."); + expect(custom).not.toContain("Answer in two sentences max."); + + const execSection = extractTagContent(message, "exec") ?? ""; + expect(execSection).toContain("Stay focused on implementation details."); + + const sonnetSection = + extractTagContent(message, "model-anthropic-claude-3-5-sonnet") ?? ""; + expect(sonnetSection).toContain("Answer in two sentences max."); + }, + }, + ]; + + for (const scenario of scopingScenarios) { + test(scenario.name, async () => { + await fs.writeFile(path.join(projectDir, "AGENTS.md"), scenario.mdContent); + + const metadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "test-project", + projectPath: projectDir, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }; + + const systemMessage = await buildSystemMessage( + metadata, + runtime, + workspaceDir, + scenario.mode, + undefined, + scenario.model + ); + + scenario.assert(systemMessage); + }); + } + }); }); diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 17f393a83..3a0b6e40c 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -3,7 +3,11 @@ import { readInstructionSet, readInstructionSetFromRuntime, } from "@/node/utils/main/instructionFiles"; -import { extractModeSection, extractModelSection } from "@/node/utils/main/markdown"; +import { + extractModeSection, + extractModelSection, + stripScopedInstructionSections, +} from "@/node/utils/main/markdown"; import type { Runtime } from "@/node/runtime/Runtime"; import { getMuxHome } from "@/common/constants/paths"; @@ -109,8 +113,18 @@ export async function buildSystemMessage( const contextInstructions = workspaceInstructions ?? (await readInstructionSet(metadata.projectPath)); - // Combine: global + context (workspace takes precedence over project) - const customInstructions = [globalInstructions, contextInstructions].filter(Boolean).join("\n\n"); + // Combine: global + context (workspace takes precedence over project) after stripping scoped sections + const sanitizeScopedInstructions = (input?: string | null): string | undefined => { + if (!input) return undefined; + const stripped = stripScopedInstructionSections(input); + return stripped.trim().length > 0 ? stripped : undefined; + }; + + const customInstructionSources = [ + sanitizeScopedInstructions(globalInstructions), + sanitizeScopedInstructions(contextInstructions), + ].filter((value): value is string => Boolean(value)); + const customInstructions = customInstructionSources.join("\n\n"); // Extract mode-specific section (context first, then global fallback) let modeContent: string | null = null; diff --git a/src/node/utils/main/markdown.ts b/src/node/utils/main/markdown.ts index c29284ed2..ba4ee7542 100644 --- a/src/node/utils/main/markdown.ts +++ b/src/node/utils/main/markdown.ts @@ -2,15 +2,21 @@ import MarkdownIt from "markdown-it"; type HeadingMatcher = (headingText: string, level: number) => boolean; -function extractSectionByHeading( +interface SectionBounds { + headingStartLine: number; + contentStartLine: number; + endLine: number; + level: number; +} + +function collectSectionBounds( markdown: string, headingMatcher: HeadingMatcher -): string | null { - if (!markdown) return null; - +): { bounds: SectionBounds[]; lines: string[] } { + const lines = markdown.split(/\r?\n/); const md = new MarkdownIt({ html: false, linkify: false, typographer: false }); const tokens = md.parse(markdown, {}); - const lines = markdown.split(/\r?\n/); + const bounds: SectionBounds[] = []; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; @@ -23,7 +29,8 @@ function extractSectionByHeading( const headingText = (inline.content || "").trim(); if (!headingMatcher(headingText, level)) continue; - const headingEndLine = inline.map?.[1] ?? token.map?.[1] ?? (token.map?.[0] ?? 0) + 1; + const headingStartLine = token.map?.[0] ?? 0; + const headingEndLine = inline.map?.[1] ?? token.map?.[1] ?? headingStartLine + 1; let endLine = lines.length; for (let j = i + 1; j < tokens.length; j++) { @@ -37,11 +44,36 @@ function extractSectionByHeading( } } - const slice = lines.slice(headingEndLine, endLine).join("\n").trim(); - return slice.length > 0 ? slice : null; + bounds.push({ headingStartLine, contentStartLine: headingEndLine, endLine, level }); + } + + return { bounds, lines }; +} + +function extractSectionByHeading(markdown: string, headingMatcher: HeadingMatcher): string | null { + if (!markdown) return null; + + const { bounds, lines } = collectSectionBounds(markdown, headingMatcher); + if (bounds.length === 0) return null; + + const { contentStartLine, endLine } = bounds[0]; + const slice = lines.slice(contentStartLine, endLine).join("\n").trim(); + return slice.length > 0 ? slice : null; +} + +function removeSectionsByHeading(markdown: string, headingMatcher: HeadingMatcher): string { + if (!markdown) return markdown; + + const { bounds, lines } = collectSectionBounds(markdown, headingMatcher); + if (bounds.length === 0) return markdown; + + const updatedLines = [...lines]; + const sortedBounds = [...bounds].sort((a, b) => b.headingStartLine - a.headingStartLine); + for (const { headingStartLine, endLine } of sortedBounds) { + updatedLines.splice(headingStartLine, endLine - headingStartLine); } - return null; + return updatedLines.join("\n"); } /** @@ -51,7 +83,10 @@ export function extractModeSection(markdown: string, mode: string): string | nul if (!markdown || !mode) return null; const expectedHeading = `mode: ${mode}`.toLowerCase(); - return extractSectionByHeading(markdown, (headingText) => headingText.toLowerCase() === expectedHeading); + return extractSectionByHeading( + markdown, + (headingText) => headingText.toLowerCase() === expectedHeading + ); } /** @@ -59,6 +94,7 @@ export function extractModeSection(markdown: string, mode: string): string | nul * the provided model identifier. Matching is case-insensitive by default unless the regex * heading explicitly specifies flags via /pattern/flags syntax. */ + export function extractModelSection(markdown: string, modelId: string): string | null { if (!markdown || !modelId) return null; @@ -90,6 +126,15 @@ export function extractModelSection(markdown: string, modelId: string): string | const match = headingPattern.exec(headingText); if (!match) return false; const regex = compileRegex(match[1] ?? ""); - return Boolean(regex && regex.test(modelId)); + return Boolean(regex?.test(modelId)); + }); +} + +export function stripScopedInstructionSections(markdown: string): string { + if (!markdown) return markdown; + + return removeSectionsByHeading(markdown, (headingText) => { + const normalized = headingText.trim().toLowerCase(); + return normalized.startsWith("mode:") || normalized.startsWith("model:"); }); }