Skip to content

Commit 60b56ab

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 4e5ac53 commit 60b56ab

File tree

5 files changed

+423
-39
lines changed

5 files changed

+423
-39
lines changed

docs/instruction-files.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Rules:
2424
- Workspace instructions are checked first, then global instructions
2525
- The first matching section wins (at most one section is used)
2626
- The section's content is everything until the next heading of the same or higher level
27+
- Mode sections are stripped from the general `<custom-instructions>` block; only the active mode's content is re-sent via its `<mode>` tag.
2728
- Missing sections are ignored (no error)
2829

2930
<!-- Note to developers: This behavior is implemented in src/services/systemMessage.ts (search for extractModeSection). Keep this documentation in sync with code changes. -->
@@ -62,6 +63,32 @@ When compacting conversation history:
6263

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

66+
## Model Prompts
67+
68+
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`).
69+
70+
Rules:
71+
72+
- Workspace instructions are evaluated before global instructions; the first matching section wins.
73+
- Regexes are case-insensitive by default. Use `/pattern/flags` syntax to opt into custom flags (e.g., `/openai:.*codex/i`).
74+
- Invalid regex patterns are ignored instead of breaking the parse.
75+
- Model sections are also removed from `<custom-instructions>`; only the first regex match (if any) is injected via its `<model-…>` tag.
76+
- Only the content under the first matching heading is injected.
77+
78+
<!-- Developers: See extractModelSection in src/node/utils/main/markdown.ts for the implementation. -->
79+
80+
Example:
81+
82+
```markdown
83+
## Model: sonnet
84+
85+
Be terse and to the point.
86+
87+
## Model: openai:.\*codex
88+
89+
Use status reporting tools every few minutes.
90+
```
91+
6592
## Practical layout
6693

6794
```

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: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import * as path from "path";
44
import { buildSystemMessage } from "./systemMessage";
55
import type { WorkspaceMetadata } from "@/common/types/workspace";
66
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
7+
8+
const extractTagContent = (message: string, tagName: string): string | null => {
9+
const pattern = new RegExp(`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`, "i");
10+
const match = pattern.exec(message);
11+
return match ? match[1].trim() : null;
12+
};
713
import { describe, test, expect, beforeEach, afterEach, spyOn, type Mock } from "bun:test";
814
import { LocalRuntime } from "@/node/runtime/LocalRuntime";
915

@@ -63,6 +69,10 @@ Use diagrams where appropriate.
6369

6470
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan");
6571

72+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
73+
expect(customInstructions).toContain("Always be helpful.");
74+
expect(customInstructions).not.toContain("Focus on planning and design.");
75+
6676
// Should include the mode-specific content
6777
expect(systemMessage).toContain("<plan>");
6878
expect(systemMessage).toContain("Focus on planning and design");
@@ -99,9 +109,9 @@ Focus on planning and design.
99109
expect(systemMessage).not.toContain("<plan>");
100110
expect(systemMessage).not.toContain("</plan>");
101111

102-
// All instructions are still in <custom-instructions> (both general and mode section)
103-
expect(systemMessage).toContain("Always be helpful");
104-
expect(systemMessage).toContain("Focus on planning and design");
112+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
113+
expect(customInstructions).toContain("Always be helpful.");
114+
expect(customInstructions).not.toContain("Focus on planning and design.");
105115
});
106116

107117
test("prefers project mode section over global mode section", async () => {
@@ -203,4 +213,211 @@ Special mode instructions.
203213
expect(systemMessage).toContain("Special mode instructions");
204214
expect(systemMessage).toContain("</my-special_mode->");
205215
});
216+
217+
test("includes model-specific section when regex matches active model", async () => {
218+
await fs.writeFile(
219+
path.join(projectDir, "AGENTS.md"),
220+
`# Instructions
221+
## Model: sonnet
222+
Respond to Sonnet tickets in two sentences max.
223+
`
224+
);
225+
226+
const metadata: WorkspaceMetadata = {
227+
id: "test-workspace",
228+
name: "test-workspace",
229+
projectName: "test-project",
230+
projectPath: projectDir,
231+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
232+
};
233+
234+
const systemMessage = await buildSystemMessage(
235+
metadata,
236+
runtime,
237+
workspaceDir,
238+
undefined,
239+
undefined,
240+
"anthropic:claude-3.5-sonnet"
241+
);
242+
243+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
244+
expect(customInstructions).not.toContain("Respond to Sonnet tickets in two sentences max.");
245+
246+
expect(systemMessage).toContain("<model-anthropic-claude-3-5-sonnet>");
247+
expect(systemMessage).toContain("Respond to Sonnet tickets in two sentences max.");
248+
expect(systemMessage).toContain("</model-anthropic-claude-3-5-sonnet>");
249+
});
250+
251+
test("falls back to global model section when project lacks a match", async () => {
252+
await fs.writeFile(
253+
path.join(globalDir, "AGENTS.md"),
254+
`# Global Instructions
255+
## Model: /openai:.*codex/i
256+
OpenAI's GPT-5.1 Codex models already default to terse replies.
257+
`
258+
);
259+
260+
await fs.writeFile(
261+
path.join(projectDir, "AGENTS.md"),
262+
`# Project Instructions
263+
General details only.
264+
`
265+
);
266+
267+
const metadata: WorkspaceMetadata = {
268+
id: "test-workspace",
269+
name: "test-workspace",
270+
projectName: "test-project",
271+
projectPath: projectDir,
272+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
273+
};
274+
275+
const systemMessage = await buildSystemMessage(
276+
metadata,
277+
runtime,
278+
workspaceDir,
279+
undefined,
280+
undefined,
281+
"openai:gpt-5.1-codex"
282+
);
283+
284+
const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? "";
285+
expect(customInstructions).not.toContain(
286+
"OpenAI's GPT-5.1 Codex models already default to terse replies."
287+
);
288+
289+
expect(systemMessage).toContain("<model-openai-gpt-5-1-codex>");
290+
expect(systemMessage).toContain(
291+
"OpenAI's GPT-5.1 Codex models already default to terse replies."
292+
);
293+
});
294+
295+
describe("instruction scoping matrix", () => {
296+
interface Scenario {
297+
name: string;
298+
mdContent: string;
299+
mode?: string;
300+
model?: string;
301+
assert: (message: string) => void;
302+
}
303+
304+
const scopingScenarios: Scenario[] = [
305+
{
306+
name: "strips scoped sections when no mode or model provided",
307+
mdContent: `# Notes
308+
General guidance for everyone.
309+
310+
## Mode: Plan
311+
Plan depth instructions.
312+
313+
## Model: sonnet
314+
Anthropic-only instructions.
315+
`,
316+
assert: (message) => {
317+
const custom = extractTagContent(message, "custom-instructions") ?? "";
318+
expect(custom).toContain("General guidance for everyone.");
319+
expect(custom).not.toContain("Plan depth instructions.");
320+
expect(custom).not.toContain("Anthropic-only instructions.");
321+
expect(message).not.toContain("Plan depth instructions.");
322+
expect(message).not.toContain("Anthropic-only instructions.");
323+
},
324+
},
325+
{
326+
name: "injects only the requested mode section",
327+
mdContent: `General context for all contributors.
328+
329+
## Mode: Plan
330+
Plan-only reminders.
331+
332+
## Mode: Exec
333+
Exec reminders.
334+
`,
335+
mode: "plan",
336+
assert: (message) => {
337+
const custom = extractTagContent(message, "custom-instructions") ?? "";
338+
expect(custom).toContain("General context for all contributors.");
339+
expect(custom).not.toContain("Plan-only reminders.");
340+
expect(custom).not.toContain("Exec reminders.");
341+
342+
const planSection = extractTagContent(message, "plan") ?? "";
343+
expect(planSection).toContain("Plan-only reminders.");
344+
expect(planSection).not.toContain("Exec reminders.");
345+
},
346+
},
347+
{
348+
name: "injects only the matching model section",
349+
mdContent: `General base instructions.
350+
351+
## Model: sonnet
352+
Anthropic-only instructions.
353+
354+
## Model: /openai:.*/
355+
OpenAI-only instructions.
356+
`,
357+
model: "openai:gpt-5.1-codex",
358+
assert: (message) => {
359+
const custom = extractTagContent(message, "custom-instructions") ?? "";
360+
expect(custom).toContain("General base instructions.");
361+
expect(custom).not.toContain("Anthropic-only instructions.");
362+
expect(custom).not.toContain("OpenAI-only instructions.");
363+
364+
const openaiSection = extractTagContent(message, "model-openai-gpt-5-1-codex") ?? "";
365+
expect(openaiSection).toContain("OpenAI-only instructions.");
366+
expect(openaiSection).not.toContain("Anthropic-only instructions.");
367+
expect(message).not.toContain("Anthropic-only instructions.");
368+
},
369+
},
370+
{
371+
name: "supports simultaneous mode and model scoping",
372+
mdContent: `General instructions for everyone.
373+
374+
## Mode: Exec
375+
Stay focused on implementation details.
376+
377+
## Model: sonnet
378+
Answer in two sentences max.
379+
`,
380+
mode: "exec",
381+
model: "anthropic:claude-3.5-sonnet",
382+
assert: (message) => {
383+
const custom = extractTagContent(message, "custom-instructions") ?? "";
384+
expect(custom).toContain("General instructions for everyone.");
385+
expect(custom).not.toContain("Stay focused on implementation details.");
386+
expect(custom).not.toContain("Answer in two sentences max.");
387+
388+
const execSection = extractTagContent(message, "exec") ?? "";
389+
expect(execSection).toContain("Stay focused on implementation details.");
390+
391+
const sonnetSection =
392+
extractTagContent(message, "model-anthropic-claude-3-5-sonnet") ?? "";
393+
expect(sonnetSection).toContain("Answer in two sentences max.");
394+
},
395+
},
396+
];
397+
398+
for (const scenario of scopingScenarios) {
399+
test(scenario.name, async () => {
400+
await fs.writeFile(path.join(projectDir, "AGENTS.md"), scenario.mdContent);
401+
402+
const metadata: WorkspaceMetadata = {
403+
id: "test-workspace",
404+
name: "test-workspace",
405+
projectName: "test-project",
406+
projectPath: projectDir,
407+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
408+
};
409+
410+
const systemMessage = await buildSystemMessage(
411+
metadata,
412+
runtime,
413+
workspaceDir,
414+
scenario.mode,
415+
undefined,
416+
scenario.model
417+
);
418+
419+
scenario.assert(systemMessage);
420+
});
421+
}
422+
});
206423
});

0 commit comments

Comments
 (0)