Skip to content

Commit 134c748

Browse files
ammar-agentrootammario
authored
🤖 feat: add model-specific instructions (#639)
## Summary - add model-scoped section extraction that mirrors the existing mode-specific behavior - thread the active model id through system message construction so matching content is emitted - document the Model: heading convention and cover it with unit tests ## Testing - bun test v1.3.1 (89fa0f34) - Generated version.ts: v0.5.1-90-gfe3b2519 (fe3b251) at 2025-11-16T21:43:17Z [0] bun run node_modules/@typescript/native-preview/bin/tsgo.js --noEmit exited with code 0 [1] bun run node_modules/@typescript/native-preview/bin/tsgo.js --noEmit -p tsconfig.main.json exited with code 0 _Generated with _ --------- Co-authored-by: root <root@ovh-1.tailc2a514.ts.net> Co-authored-by: Ammar Bandukwala <ammar@ammar.io>
1 parent 816297d commit 134c748

File tree

5 files changed

+206
-30
lines changed

5 files changed

+206
-30
lines changed

docs/instruction-files.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,31 @@ When compacting conversation history:
6262

6363
Customizing the `compact` mode is particularly useful for controlling what information is preserved during automatic history compaction.
6464

65+
## Model Prompts
66+
67+
Similar to modes, mux reads headings titled `Model: <regex>` to scope instructions to specific models or families. The `<regex>` is matched against the full model identifier (for example, `openai:gpt-5.1-codex`).
68+
69+
Rules:
70+
71+
- Workspace instructions are evaluated before global instructions; the first matching section wins.
72+
- Regexes are case-insensitive by default. Use `/pattern/flags` syntax to opt into custom flags (e.g., `/openai:.*codex/i`).
73+
- Invalid regex patterns are ignored instead of breaking the parse.
74+
- Only the content under the first matching heading is injected.
75+
76+
<!-- Developers: See extractModelSection in src/node/utils/main/markdown.ts for the implementation. -->
77+
78+
Example:
79+
80+
```markdown
81+
## Model: sonnet
82+
83+
Be terse and to the point.
84+
85+
## Model: openai:.\*codex
86+
87+
Use status reporting tools every few minutes.
88+
```
89+
6590
## Practical layout
6691

6792
```

src/node/services/aiService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,8 @@ export class AIService extends EventEmitter {
655655
runtime,
656656
workspacePath,
657657
mode,
658-
additionalSystemInstructions
658+
additionalSystemInstructions,
659+
modelString
659660
);
660661

661662
// Count system message tokens for cost tracking

src/node/services/systemMessage.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,74 @@ Special mode instructions.
203203
expect(systemMessage).toContain("Special mode instructions");
204204
expect(systemMessage).toContain("</my-special_mode->");
205205
});
206+
207+
test("includes model-specific section when regex matches active model", async () => {
208+
await fs.writeFile(
209+
path.join(projectDir, "AGENTS.md"),
210+
`# Instructions
211+
## Model: sonnet
212+
Respond to Sonnet tickets in two sentences max.
213+
`
214+
);
215+
216+
const metadata: WorkspaceMetadata = {
217+
id: "test-workspace",
218+
name: "test-workspace",
219+
projectName: "test-project",
220+
projectPath: projectDir,
221+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
222+
};
223+
224+
const systemMessage = await buildSystemMessage(
225+
metadata,
226+
runtime,
227+
workspaceDir,
228+
undefined,
229+
undefined,
230+
"anthropic:claude-3.5-sonnet"
231+
);
232+
233+
expect(systemMessage).toContain("<model-anthropic-claude-3-5-sonnet>");
234+
expect(systemMessage).toContain("Respond to Sonnet tickets in two sentences max.");
235+
expect(systemMessage).toContain("</model-anthropic-claude-3-5-sonnet>");
236+
});
237+
238+
test("falls back to global model section when project lacks a match", async () => {
239+
await fs.writeFile(
240+
path.join(globalDir, "AGENTS.md"),
241+
`# Global Instructions
242+
## Model: /openai:.*codex/i
243+
OpenAI's GPT-5.1 Codex models already default to terse replies.
244+
`
245+
);
246+
247+
await fs.writeFile(
248+
path.join(projectDir, "AGENTS.md"),
249+
`# Project Instructions
250+
General details only.
251+
`
252+
);
253+
254+
const metadata: WorkspaceMetadata = {
255+
id: "test-workspace",
256+
name: "test-workspace",
257+
projectName: "test-project",
258+
projectPath: projectDir,
259+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
260+
};
261+
262+
const systemMessage = await buildSystemMessage(
263+
metadata,
264+
runtime,
265+
workspaceDir,
266+
undefined,
267+
undefined,
268+
"openai:gpt-5.1-codex"
269+
);
270+
271+
expect(systemMessage).toContain("<model-openai-gpt-5-1-codex>");
272+
expect(systemMessage).toContain(
273+
"OpenAI's GPT-5.1 Codex models already default to terse replies."
274+
);
275+
});
206276
});

src/node/services/systemMessage.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
readInstructionSet,
44
readInstructionSetFromRuntime,
55
} from "@/node/utils/main/instructionFiles";
6-
import { extractModeSection } from "@/node/utils/main/markdown";
6+
import { extractModeSection, extractModelSection } from "@/node/utils/main/markdown";
77
import type { Runtime } from "@/node/runtime/Runtime";
88
import { getMuxHome } from "@/common/constants/paths";
99

@@ -12,6 +12,24 @@ import { getMuxHome } from "@/common/constants/paths";
1212
// The PRELUDE is intentionally minimal to not conflict with the user's instructions.
1313
// mux is designed to be model agnostic, and models have shown large inconsistency in how they
1414
// follow instructions.
15+
16+
function sanitizeSectionTag(value: string | undefined, fallback: string): string {
17+
const normalized = (value ?? "")
18+
.toLowerCase()
19+
.replace(/[^a-z0-9_-]/gi, "-")
20+
.replace(/-+/g, "-");
21+
return normalized.length > 0 ? normalized : fallback;
22+
}
23+
24+
function buildTaggedSection(
25+
content: string | null,
26+
rawTagValue: string | undefined,
27+
fallback: string
28+
): string {
29+
if (!content) return "";
30+
const tag = sanitizeSectionTag(rawTagValue, fallback);
31+
return `\n\n<${tag}>\n${content}\n</${tag}>`;
32+
}
1533
const PRELUDE = `
1634
<prelude>
1735
You are a coding agent.
@@ -71,14 +89,16 @@ function getSystemDirectory(): string {
7189
* @param workspacePath - Workspace directory path
7290
* @param mode - Optional mode name (e.g., "plan", "exec")
7391
* @param additionalSystemInstructions - Optional instructions appended last
92+
* @param modelString - Active model identifier used for Model-specific sections
7493
* @throws Error if metadata or workspacePath invalid
7594
*/
7695
export async function buildSystemMessage(
7796
metadata: WorkspaceMetadata,
7897
runtime: Runtime,
7998
workspacePath: string,
8099
mode?: string,
81-
additionalSystemInstructions?: string
100+
additionalSystemInstructions?: string,
101+
modelString?: string
82102
): Promise<string> {
83103
if (!metadata) throw new Error("Invalid workspace metadata: metadata is required");
84104
if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required");
@@ -101,16 +121,32 @@ export async function buildSystemMessage(
101121
null;
102122
}
103123

124+
// Extract model-specific section based on active model identifier (context first)
125+
let modelContent: string | null = null;
126+
if (modelString) {
127+
modelContent =
128+
(contextInstructions && extractModelSection(contextInstructions, modelString)) ??
129+
(globalInstructions && extractModelSection(globalInstructions, modelString)) ??
130+
null;
131+
}
132+
104133
// Build system message
105134
let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(workspacePath)}`;
106135

107136
if (customInstructions) {
108137
systemMessage += `\n<custom-instructions>\n${customInstructions}\n</custom-instructions>`;
109138
}
110139

111-
if (modeContent) {
112-
const tag = (mode ?? "mode").toLowerCase().replace(/[^a-z0-9_-]/gi, "-");
113-
systemMessage += `\n\n<${tag}>\n${modeContent}\n</${tag}>`;
140+
const modeSection = buildTaggedSection(modeContent, mode, "mode");
141+
if (modeSection) {
142+
systemMessage += modeSection;
143+
}
144+
145+
if (modelContent && modelString) {
146+
const modelSection = buildTaggedSection(modelContent, `model-${modelString}`, "model");
147+
if (modelSection) {
148+
systemMessage += modelSection;
149+
}
114150
}
115151

116152
if (additionalSystemInstructions) {

src/node/utils/main/markdown.ts

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,34 @@
11
import MarkdownIt from "markdown-it";
22

3-
/**
4-
* Extract the content under a heading titled "Mode: <mode>" (case-insensitive).
5-
* - Matches any heading level (#..######)
6-
* - Returns raw markdown content between this heading and the next heading
7-
* of the same or higher level in the same document
8-
* - If multiple sections match, the first one wins
9-
* - The heading line itself is excluded from the returned content
10-
*/
11-
export function extractModeSection(markdown: string, mode: string): string | null {
12-
if (!markdown || !mode) return null;
3+
type HeadingMatcher = (headingText: string, level: number) => boolean;
4+
5+
function extractSectionByHeading(markdown: string, headingMatcher: HeadingMatcher): string | null {
6+
if (!markdown) return null;
137

148
const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
159
const tokens = md.parse(markdown, {});
1610
const lines = markdown.split(/\r?\n/);
17-
const target = `mode: ${mode}`.toLowerCase();
1811

1912
for (let i = 0; i < tokens.length; i++) {
20-
const t = tokens[i];
21-
if (t.type !== "heading_open") continue;
13+
const token = tokens[i];
14+
if (token.type !== "heading_open") continue;
2215

23-
const level = Number(t.tag?.replace(/^h/, "")) || 1;
16+
const level = Number(token.tag?.replace(/^h/, "")) || 1;
2417
const inline = tokens[i + 1];
2518
if (inline?.type !== "inline") continue;
2619

27-
const text = (inline.content || "").trim().toLowerCase();
28-
if (text !== target) continue;
20+
const headingText = (inline.content || "").trim();
21+
if (!headingMatcher(headingText, level)) continue;
2922

30-
// Start content after the heading block ends
31-
const headingEndLine = inline.map?.[1] ?? t.map?.[1] ?? (t.map?.[0] ?? 0) + 1;
23+
const headingEndLine = inline.map?.[1] ?? token.map?.[1] ?? (token.map?.[0] ?? 0) + 1;
3224

33-
// Find the next heading of same or higher level to bound the section
34-
let endLine = lines.length; // exclusive
25+
let endLine = lines.length;
3526
for (let j = i + 1; j < tokens.length; j++) {
36-
const tt = tokens[j];
37-
if (tt.type === "heading_open") {
38-
const nextLevel = Number(tt.tag?.replace(/^h/, "")) || 1;
27+
const nextToken = tokens[j];
28+
if (nextToken.type === "heading_open") {
29+
const nextLevel = Number(nextToken.tag?.replace(/^h/, "")) || 1;
3930
if (nextLevel <= level) {
40-
endLine = tt.map?.[0] ?? endLine;
31+
endLine = nextToken.map?.[0] ?? endLine;
4132
break;
4233
}
4334
}
@@ -49,3 +40,56 @@ export function extractModeSection(markdown: string, mode: string): string | nul
4940

5041
return null;
5142
}
43+
44+
/**
45+
* Extract the content under a heading titled "Mode: <mode>" (case-insensitive).
46+
*/
47+
export function extractModeSection(markdown: string, mode: string): string | null {
48+
if (!markdown || !mode) return null;
49+
50+
const expectedHeading = `mode: ${mode}`.toLowerCase();
51+
return extractSectionByHeading(
52+
markdown,
53+
(headingText) => headingText.toLowerCase() === expectedHeading
54+
);
55+
}
56+
57+
/**
58+
* Extract the first section whose heading matches "Model: <regex>" and whose regex matches
59+
* the provided model identifier. Matching is case-insensitive by default unless the regex
60+
* heading explicitly specifies flags via /pattern/flags syntax.
61+
*/
62+
export function extractModelSection(markdown: string, modelId: string): string | null {
63+
if (!markdown || !modelId) return null;
64+
65+
const headingPattern = /^model:\s*(.+)$/i;
66+
67+
const compileRegex = (pattern: string): RegExp | null => {
68+
const trimmed = pattern.trim();
69+
if (!trimmed) return null;
70+
71+
if (trimmed.startsWith("/") && trimmed.lastIndexOf("/") > 0) {
72+
const lastSlash = trimmed.lastIndexOf("/");
73+
const source = trimmed.slice(1, lastSlash);
74+
const flags = trimmed.slice(lastSlash + 1);
75+
try {
76+
return new RegExp(source, flags || undefined);
77+
} catch {
78+
return null;
79+
}
80+
}
81+
82+
try {
83+
return new RegExp(trimmed, "i");
84+
} catch {
85+
return null;
86+
}
87+
};
88+
89+
return extractSectionByHeading(markdown, (headingText) => {
90+
const match = headingPattern.exec(headingText);
91+
if (!match) return false;
92+
const regex = compileRegex(match[1] ?? "");
93+
return Boolean(regex?.test(modelId));
94+
});
95+
}

0 commit comments

Comments
 (0)