Skip to content

Commit e8b0b9c

Browse files
committed
Fix AGENTS.md loading: workspace first, project fallback
Add Runtime abstraction support for reading AGENTS.md from workspaces, with graceful fallback to project root when workspace file doesn't exist. Problem: - SSH workspaces are on remote machines, can't use local fs.readFile() - AGENTS.md may not be cloned into SSH workspace yet - Need to support both local worktrees and remote SSH workspaces Solution: - Add runtime parameter to buildSystemMessage() - Read workspace AGENTS.md using Runtime.readFile() (works for SSH) - Fall back to project root AGENTS.md if workspace doesn't have one - Preserves branch-specific instructions for local workspaces - Provides sensible defaults for SSH workspaces during clone Changes: - buildSystemMessage() now accepts Runtime parameter - New readInstructionSetFromRuntime() uses runtime to read workspace files - Instruction priority: global → workspace (if exists) → project (fallback) - Mode section priority: workspace → project → global - Updated docs/instruction-files.md to reflect fallback behavior - Updated all tests to pass runtime parameter - Updated aiService.ts to pass runtime to buildSystemMessage() This preserves existing test behavior (workspace-specific instructions work) while adding robustness for SSH workspaces where files may not exist yet.
1 parent 09772a0 commit e8b0b9c

File tree

5 files changed

+96
-25
lines changed

5 files changed

+96
-25
lines changed

docs/instruction-files.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
## Overview
44

5-
cmux layers instructions from two locations:
5+
cmux layers instructions from multiple locations:
66

77
1. `~/.cmux/AGENTS.md` (+ optional `AGENTS.local.md`) — global defaults
8-
2. `<project>/AGENTS.md` (+ optional `AGENTS.local.md`) — project-specific context
8+
2. `<workspace>/AGENTS.md` (+ optional `AGENTS.local.md`) — workspace-specific context (if exists)
9+
3. `<project>/AGENTS.md` (+ optional `AGENTS.local.md`) — project fallback
910

1011
Priority within each location: `AGENTS.md``AGENT.md``CLAUDE.md` (first match wins). If the base file is found, cmux also appends `AGENTS.local.md` from the same directory when present.
1112

12-
**Note**: Instructions are read from the project root (where the main repository is located), not from individual workspace directories. This ensures consistent instructions across all workspaces for a project.
13+
**Fallback behavior**: If a workspace doesn't have its own AGENTS.md, the project root's AGENTS.md is used as a fallback. This is particularly useful for SSH workspaces where files may not be fully cloned yet.
1314

1415
## Mode Prompts
1516

@@ -21,7 +22,7 @@ cmux reads mode context from sections inside your instruction files. Add a headi
2122

2223
Rules:
2324

24-
- Project instructions are checked first, then global instructions
25+
- Workspace instructions are checked first, then project, then global instructions
2526
- The first matching section wins (at most one section is used)
2627
- The section's content is everything until the next heading of the same or higher level
2728
- Missing sections are ignored (no error)

src/services/aiService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ export class AIService extends EventEmitter {
508508
// Build system message from workspace metadata
509509
const systemMessage = await buildSystemMessage(
510510
metadata,
511+
runtime,
511512
workspacePath,
512513
mode,
513514
additionalSystemInstructions

src/services/systemMessage.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { buildSystemMessage } from "./systemMessage";
55
import type { WorkspaceMetadata } from "@/types/workspace";
66
import { spyOn, describe, test, expect, beforeEach, afterEach } from "bun:test";
77
import type { Mock } from "bun:test";
8+
import { LocalRuntime } from "@/runtime/LocalRuntime";
89

910
describe("buildSystemMessage", () => {
1011
let tempDir: string;
1112
let projectDir: string;
1213
let workspaceDir: string;
1314
let globalDir: string;
1415
let mockHomedir: Mock<typeof os.homedir>;
16+
let runtime: LocalRuntime;
1517

1618
beforeEach(async () => {
1719
// Create temp directory for test
@@ -26,6 +28,9 @@ describe("buildSystemMessage", () => {
2628
// Mock homedir to return our test directory (getSystemDirectory will append .cmux)
2729
mockHomedir = spyOn(os, "homedir");
2830
mockHomedir.mockReturnValue(tempDir);
31+
32+
// Create a local runtime for tests
33+
runtime = new LocalRuntime(tempDir);
2934
});
3035

3136
afterEach(async () => {
@@ -55,7 +60,7 @@ Use diagrams where appropriate.
5560
projectPath: projectDir,
5661
};
5762

58-
const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan");
63+
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan");
5964

6065
// Should include the mode-specific content
6166
expect(systemMessage).toContain("<plan>");
@@ -86,7 +91,7 @@ Focus on planning and design.
8691
projectPath: projectDir,
8792
};
8893

89-
const systemMessage = await buildSystemMessage(metadata, workspaceDir);
94+
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir);
9095

9196
// Should NOT include the <plan> mode-specific tag
9297
expect(systemMessage).not.toContain("<plan>");
@@ -125,7 +130,7 @@ Project plan instructions (should win).
125130
projectPath: projectDir,
126131
};
127132

128-
const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan");
133+
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan");
129134

130135
// Should include project mode section in the <plan> tag (project wins)
131136
expect(systemMessage).toMatch(/<plan>\s*Project plan instructions \(should win\)\./s);
@@ -160,7 +165,7 @@ Just general project stuff.
160165
projectPath: projectDir,
161166
};
162167

163-
const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan");
168+
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan");
164169

165170
// Should include global mode section as fallback
166171
expect(systemMessage).toContain("Global plan instructions");
@@ -181,7 +186,7 @@ Special mode instructions.
181186
projectPath: projectDir,
182187
};
183188

184-
const systemMessage = await buildSystemMessage(metadata, workspaceDir, "My-Special_Mode!");
189+
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "My-Special_Mode!");
185190

186191
// Tag should be sanitized to only contain valid characters
187192
expect(systemMessage).toContain("<my-special_mode->");

src/services/systemMessage.ts

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import * as os from "os";
22
import * as path from "path";
33
import type { WorkspaceMetadata } from "@/types/workspace";
4-
import { gatherInstructionSets, readInstructionSet } from "@/utils/main/instructionFiles";
4+
import { gatherInstructionSets, readInstructionSet, INSTRUCTION_FILE_NAMES } from "@/utils/main/instructionFiles";
55
import { extractModeSection } from "@/utils/main/markdown";
6+
import type { Runtime } from "@/runtime/Runtime";
7+
import { readFileString } from "@/utils/runtime/helpers";
68

79
// NOTE: keep this in sync with the docs/models.md file
810

@@ -50,15 +52,57 @@ function getSystemDirectory(): string {
5052
return path.join(os.homedir(), ".cmux");
5153
}
5254

55+
/**
56+
* Read instruction set from a workspace using the runtime abstraction.
57+
* This supports both local workspaces and remote SSH workspaces.
58+
*
59+
* @param runtime - Runtime instance (may be local or SSH)
60+
* @param workspacePath - Path to workspace directory
61+
* @returns Combined instruction content, or null if no base file exists
62+
*/
63+
async function readInstructionSetFromRuntime(
64+
runtime: Runtime,
65+
workspacePath: string
66+
): Promise<string | null> {
67+
const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md";
68+
69+
// Try to read base instruction file
70+
let baseContent: string | null = null;
71+
for (const filename of INSTRUCTION_FILE_NAMES) {
72+
try {
73+
const filePath = path.join(workspacePath, filename);
74+
baseContent = await readFileString(runtime, filePath);
75+
break; // Found one, stop searching
76+
} catch {
77+
// File doesn't exist or can't be read, try next
78+
continue;
79+
}
80+
}
81+
82+
if (!baseContent) {
83+
return null;
84+
}
85+
86+
// Try to read local variant
87+
try {
88+
const localFilePath = path.join(workspacePath, LOCAL_INSTRUCTION_FILENAME);
89+
const localContent = await readFileString(runtime, localFilePath);
90+
return `${baseContent}\n\n${localContent}`;
91+
} catch {
92+
return baseContent;
93+
}
94+
}
95+
5396
/**
5497
* Builds a system message for the AI model by combining multiple instruction sources.
5598
*
5699
* Instruction sources are layered in this order:
57100
* 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md)
58-
* 2. Project instructions: <projectPath>/AGENTS.md (+ AGENTS.local.md)
59-
* 3. Mode-specific context (if mode provided): Extract a section titled "Mode: <mode>"
101+
* 2. Workspace instructions: <workspacePath>/AGENTS.md (+ AGENTS.local.md) - if exists
102+
* 3. Project instructions: <projectPath>/AGENTS.md (+ AGENTS.local.md) - fallback if workspace doesn't have one
103+
* 4. Mode-specific context (if mode provided): Extract a section titled "Mode: <mode>"
60104
* (case-insensitive) from the instruction file. We search at most one section in
61-
* precedence order: project instructions first, then global instructions.
105+
* precedence order: workspace instructions first, then project, then global instructions.
62106
*
63107
* Each instruction file location is searched for in priority order:
64108
* - AGENTS.md
@@ -69,6 +113,7 @@ function getSystemDirectory(): string {
69113
* checked and appended when building the instruction set (useful for personal preferences not committed to git).
70114
*
71115
* @param metadata - Workspace metadata (contains projectPath for reading AGENTS.md)
116+
* @param runtime - Runtime instance for reading workspace files (may be remote)
72117
* @param workspacePath - Absolute path to the workspace directory (for environment context)
73118
* @param mode - Optional mode name (e.g., "plan", "exec") - looks for {MODE}.md files if provided
74119
* @param additionalSystemInstructions - Optional additional system instructions to append at the end
@@ -77,6 +122,7 @@ function getSystemDirectory(): string {
77122
*/
78123
export async function buildSystemMessage(
79124
metadata: WorkspaceMetadata,
125+
runtime: Runtime,
80126
workspacePath: string,
81127
mode?: string,
82128
additionalSystemInstructions?: string
@@ -92,20 +138,37 @@ export async function buildSystemMessage(
92138
const systemDir = getSystemDirectory();
93139
const projectDir = metadata.projectPath;
94140

95-
// Gather instruction sets from both global and project directories
96-
// Global instructions apply first, then project-specific ones
97-
// Note: We read from projectPath (the main repo) not workspacePath (the worktree)
98-
const instructionDirectories = [systemDir, projectDir];
99-
const instructionSegments = await gatherInstructionSets(instructionDirectories);
100-
const customInstructions = instructionSegments.join("\n\n");
141+
// Read workspace instructions using runtime (may be remote for SSH)
142+
// Try to read AGENTS.md from workspace directory first
143+
const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath);
144+
145+
// Gather instruction sets from global and project directories (always local)
146+
// Note: We gather from both systemDir and projectDir, but workspace is handled separately
147+
const localInstructionDirs = [systemDir, projectDir];
148+
const localInstructionSegments = await gatherInstructionSets(localInstructionDirs);
149+
150+
// Combine all instruction sources
151+
// Priority: global, workspace (if found), project (as fallback)
152+
const allSegments = [...localInstructionSegments];
153+
if (workspaceInstructions) {
154+
// Insert workspace instructions after global (index 0) but before project
155+
allSegments.splice(1, 0, workspaceInstructions);
156+
}
157+
const customInstructions = allSegments.join("\n\n");
101158

102-
// Look for a "Mode: <mode>" section inside instruction sets, preferring project over global
159+
// Look for a "Mode: <mode>" section inside instruction sets
160+
// Priority: workspace instructions, then project, then global
103161
// This behavior is documented in docs/instruction-files.md - keep both in sync when changing.
104162
let modeContent: string | null = null;
105163
if (mode) {
106-
const projectInstructions = await readInstructionSet(projectDir);
107-
if (projectInstructions) {
108-
modeContent = extractModeSection(projectInstructions, mode);
164+
if (workspaceInstructions) {
165+
modeContent = extractModeSection(workspaceInstructions, mode);
166+
}
167+
if (!modeContent) {
168+
const projectInstructions = await readInstructionSet(projectDir);
169+
if (projectInstructions) {
170+
modeContent = extractModeSection(projectInstructions, mode);
171+
}
109172
}
110173
if (!modeContent) {
111174
const globalInstructions = await readInstructionSet(systemDir);

tests/ipcMain/sendMessage.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -700,10 +700,11 @@ describeIntegration("IpcMain sendMessage integration tests", () => {
700700
"should include mode-specific instructions in system message",
701701
async () => {
702702
// Setup test environment
703-
const { env, workspaceId, workspacePath, cleanup } = await setupWorkspace(provider);
703+
const { env, workspaceId, tempGitRepo, cleanup } = await setupWorkspace(provider);
704704
try {
705705
// Write AGENTS.md with mode-specific sections containing distinctive markers
706-
const agentsMdPath = path.join(workspacePath, "AGENTS.md");
706+
// Note: AGENTS.md is read from project root, not workspace directory
707+
const agentsMdPath = path.join(tempGitRepo, "AGENTS.md");
707708
const agentsMdContent = `# Instructions
708709
709710
## General Instructions

0 commit comments

Comments
 (0)