Skip to content

Commit 82b7a96

Browse files
NikAiyerMastra Code (anthropic/claude-sonnet-4)
andauthored
fix(core): detect SKILL.md file changes for hot reload (#15676)
Co-authored-by: Mastra Code (anthropic/claude-sonnet-4) <noreply@mastra.ai>
1 parent 090c955 commit 82b7a96

4 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Add opt-in `checkSkillFileMtime` option to detect in-place SKILL.md edits during hot reload.
6+
7+
Previously, only directory mtime was checked for skill staleness, so editing a skill's name (to fix a validation error) or updating its description wouldn't trigger re-discovery until server restart.
8+
9+
The option is off by default since it doubles `stat()` calls per skill during staleness checks. Recommended for local development only, not for cloud storage backends where `stat()` has higher latency.
10+
11+
```ts
12+
const myAgent = new Agent({
13+
workspace: {
14+
filesystem: new LocalFilesystem({ basePath: process.cwd() }),
15+
skills: ['./**/skills'],
16+
checkSkillFileMtime: true, // Enable for local dev
17+
},
18+
});
19+
```

packages/core/src/workspace/skills/workspace-skills.test.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,314 @@ Instructions for the new skill.`;
10981098
});
10991099
});
11001100

1101+
describe('maybeRefresh SKILL.md file mtime detection', () => {
1102+
/**
1103+
* Helper to mock stat with separate mtimes for directories vs files.
1104+
* This simulates the real filesystem behavior where editing a file's content
1105+
* updates the file's mtime but not its parent directory's mtime.
1106+
*/
1107+
function mockSplitMtimeStat(filesystem: MockSkillSource, getDirMtime: () => Date, getFileMtime: () => Date) {
1108+
(filesystem.stat as ReturnType<typeof vi.fn>).mockImplementation(
1109+
async (path: string): Promise<SkillSourceStat> => {
1110+
const exists = await filesystem.exists(path);
1111+
if (!exists) {
1112+
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
1113+
}
1114+
const isFile = path.endsWith('.md');
1115+
const mtime = isFile ? getFileMtime() : getDirMtime();
1116+
return {
1117+
name: path.split('/').pop() || path,
1118+
type: isFile ? ('file' as const) : ('directory' as const),
1119+
size: 0,
1120+
createdAt: mtime,
1121+
modifiedAt: mtime,
1122+
};
1123+
},
1124+
);
1125+
}
1126+
1127+
it('should detect SKILL.md content changes even when directory mtime unchanged', async () => {
1128+
vi.useFakeTimers();
1129+
try {
1130+
// Separate mtimes for directories vs files
1131+
const dirMtime = new Date(Date.now() - 10000); // Old, never changes
1132+
let fileMtime = new Date(Date.now() - 10000); // Starts old, will be updated
1133+
1134+
const filesMap: Record<string, string> = {
1135+
'skills/test-skill/SKILL.md': `---
1136+
name: bad-name
1137+
description: A test skill with invalid name
1138+
---
1139+
# Test Skill
1140+
Instructions here.`,
1141+
};
1142+
1143+
const filesystem = createMockFilesystem(filesMap);
1144+
mockSplitMtimeStat(
1145+
filesystem,
1146+
() => dirMtime,
1147+
() => fileMtime,
1148+
);
1149+
1150+
const skills = new WorkspaceSkillsImpl({
1151+
source: filesystem,
1152+
skills: ['skills'],
1153+
validateOnLoad: true,
1154+
checkSkillFileMtime: true, // Enable opt-in file mtime detection
1155+
});
1156+
1157+
// Initial discovery - skill has invalid name, should not be loaded
1158+
const initialList = await skills.list();
1159+
expect(initialList).toHaveLength(0);
1160+
1161+
// Advance past the staleness check cooldown
1162+
vi.advanceTimersByTime(WorkspaceSkillsImpl.STALENESS_CHECK_COOLDOWN + 100);
1163+
1164+
// Fix the SKILL.md content (only file mtime changes, not directory)
1165+
const fixedSkillMd = `---
1166+
name: test-skill
1167+
description: A test skill with valid name
1168+
---
1169+
# Test Skill
1170+
Instructions here.`;
1171+
await filesystem.writeFile('skills/test-skill/SKILL.md', fixedSkillMd);
1172+
1173+
// Update only the file mtime, directory stays old
1174+
fileMtime = new Date(Date.now() + 1000);
1175+
1176+
// maybeRefresh should detect the file change and reload
1177+
await skills.maybeRefresh();
1178+
const afterRefresh = await skills.list();
1179+
1180+
// With checkSkillFileMtime: true, SKILL.md file changes are detected
1181+
expect(afterRefresh).toHaveLength(1);
1182+
expect(afterRefresh[0]!.name).toBe('test-skill');
1183+
} finally {
1184+
vi.useRealTimers();
1185+
}
1186+
});
1187+
1188+
it('should detect SKILL.md content updates for existing valid skills', async () => {
1189+
vi.useFakeTimers();
1190+
try {
1191+
const dirMtime = new Date(Date.now() - 10000);
1192+
let fileMtime = new Date(Date.now() - 10000);
1193+
1194+
const filesMap: Record<string, string> = {
1195+
'skills/test-skill/SKILL.md': VALID_SKILL_MD,
1196+
};
1197+
1198+
const filesystem = createMockFilesystem(filesMap);
1199+
mockSplitMtimeStat(
1200+
filesystem,
1201+
() => dirMtime,
1202+
() => fileMtime,
1203+
);
1204+
1205+
const skills = new WorkspaceSkillsImpl({
1206+
source: filesystem,
1207+
skills: ['skills'],
1208+
checkSkillFileMtime: true, // Enable opt-in file mtime detection
1209+
});
1210+
1211+
// Initial discovery
1212+
const initialList = await skills.list();
1213+
expect(initialList).toHaveLength(1);
1214+
expect(initialList[0]!.description).toBe('A test skill for unit testing');
1215+
1216+
const initialReadFileCalls = (filesystem.readFile as ReturnType<typeof vi.fn>).mock.calls.length;
1217+
1218+
// Advance past cooldown
1219+
vi.advanceTimersByTime(WorkspaceSkillsImpl.STALENESS_CHECK_COOLDOWN + 100);
1220+
1221+
// Update SKILL.md content (description change)
1222+
const updatedSkillMd = `---
1223+
name: test-skill
1224+
description: Updated description for the skill
1225+
---
1226+
# Test Skill
1227+
Updated instructions.`;
1228+
await filesystem.writeFile('skills/test-skill/SKILL.md', updatedSkillMd);
1229+
1230+
// Only file mtime changes
1231+
fileMtime = new Date(Date.now() + 1000);
1232+
1233+
await skills.maybeRefresh();
1234+
const afterRefreshCalls = (filesystem.readFile as ReturnType<typeof vi.fn>).mock.calls.length;
1235+
1236+
// Should have re-read the file
1237+
expect(afterRefreshCalls).toBeGreaterThan(initialReadFileCalls);
1238+
1239+
const afterRefresh = await skills.list();
1240+
expect(afterRefresh).toHaveLength(1);
1241+
expect(afterRefresh[0]!.description).toBe('Updated description for the skill');
1242+
} finally {
1243+
vi.useRealTimers();
1244+
}
1245+
});
1246+
1247+
it('should detect new agent-created skills via directory mtime change (checkSkillFileMtime not required)', async () => {
1248+
vi.useFakeTimers();
1249+
try {
1250+
let dirMtime = new Date(Date.now() - 10000);
1251+
let fileMtime = new Date(Date.now() - 10000);
1252+
1253+
const filesMap: Record<string, string> = {
1254+
'skills/test-skill/SKILL.md': VALID_SKILL_MD, // name: test-skill matches directory
1255+
};
1256+
1257+
const filesystem = createMockFilesystem(filesMap);
1258+
mockSplitMtimeStat(
1259+
filesystem,
1260+
() => dirMtime,
1261+
() => fileMtime,
1262+
);
1263+
1264+
// Note: checkSkillFileMtime is NOT enabled - directory mtime detection should still work
1265+
const skills = new WorkspaceSkillsImpl({
1266+
source: filesystem,
1267+
skills: ['skills'],
1268+
});
1269+
1270+
// Initial discovery
1271+
const initialList = await skills.list();
1272+
expect(initialList).toHaveLength(1);
1273+
1274+
// Advance past cooldown
1275+
vi.advanceTimersByTime(WorkspaceSkillsImpl.STALENESS_CHECK_COOLDOWN + 100);
1276+
1277+
// Agent creates a new skill by writing files directly
1278+
const newSkillMd = `---
1279+
name: agent-created-skill
1280+
description: A skill created by the agent
1281+
---
1282+
# Agent Created Skill
1283+
This skill was created programmatically.`;
1284+
await filesystem.writeFile('skills/agent-created-skill/SKILL.md', newSkillMd);
1285+
1286+
// Creating new directory updates parent directory mtime - this triggers refresh
1287+
dirMtime = new Date(Date.now() + 1000);
1288+
fileMtime = new Date(Date.now() + 1000);
1289+
1290+
await skills.maybeRefresh();
1291+
const afterRefresh = await skills.list();
1292+
1293+
expect(afterRefresh).toHaveLength(2);
1294+
expect(afterRefresh.map(s => s.name).sort()).toEqual(['agent-created-skill', 'test-skill']);
1295+
} finally {
1296+
vi.useRealTimers();
1297+
}
1298+
});
1299+
1300+
it('should NOT detect SKILL.md file changes by default (checkSkillFileMtime: false)', async () => {
1301+
vi.useFakeTimers();
1302+
try {
1303+
const dirMtime = new Date(Date.now() - 10000);
1304+
let fileMtime = new Date(Date.now() - 10000);
1305+
1306+
const filesMap: Record<string, string> = {
1307+
'skills/test-skill/SKILL.md': `---
1308+
name: bad-name
1309+
description: A test skill with invalid name
1310+
---
1311+
# Test Skill
1312+
Instructions here.`,
1313+
};
1314+
1315+
const filesystem = createMockFilesystem(filesMap);
1316+
mockSplitMtimeStat(
1317+
filesystem,
1318+
() => dirMtime,
1319+
() => fileMtime,
1320+
);
1321+
1322+
// Default: checkSkillFileMtime is false
1323+
const skills = new WorkspaceSkillsImpl({
1324+
source: filesystem,
1325+
skills: ['skills'],
1326+
validateOnLoad: true,
1327+
});
1328+
1329+
// Initial discovery - skill has invalid name
1330+
const initialList = await skills.list();
1331+
expect(initialList).toHaveLength(0);
1332+
1333+
vi.advanceTimersByTime(WorkspaceSkillsImpl.STALENESS_CHECK_COOLDOWN + 100);
1334+
1335+
// Fix the SKILL.md content (only file mtime changes)
1336+
const fixedSkillMd = `---
1337+
name: test-skill
1338+
description: A test skill with valid name
1339+
---
1340+
# Test Skill
1341+
Instructions here.`;
1342+
await filesystem.writeFile('skills/test-skill/SKILL.md', fixedSkillMd);
1343+
fileMtime = new Date(Date.now() + 1000);
1344+
1345+
// Without checkSkillFileMtime, this should NOT detect the change
1346+
await skills.maybeRefresh();
1347+
const afterRefresh = await skills.list();
1348+
1349+
// Still 0 because file mtime change was not detected
1350+
expect(afterRefresh).toHaveLength(0);
1351+
} finally {
1352+
vi.useRealTimers();
1353+
}
1354+
});
1355+
1356+
it('should detect SKILL.md changes for direct skill paths (e.g., **/SKILL.md glob)', async () => {
1357+
vi.useFakeTimers();
1358+
try {
1359+
// Separate mtimes for directories vs files
1360+
const dirMtime = new Date(Date.now() - 10000); // Old, never changes
1361+
let fileMtime = new Date(Date.now() - 10000); // Starts old, will be updated
1362+
1363+
const filesMap: Record<string, string> = {
1364+
'skills/my-skill/SKILL.md': VALID_SKILL_MD,
1365+
};
1366+
1367+
const filesystem = createMockFilesystem(filesMap);
1368+
mockSplitMtimeStat(
1369+
filesystem,
1370+
() => dirMtime,
1371+
() => fileMtime,
1372+
);
1373+
1374+
// Direct skill path (simulates glob that resolved directly to a skill directory)
1375+
const skills = new WorkspaceSkillsImpl({
1376+
source: filesystem,
1377+
skills: ['skills/my-skill'], // Direct skill path
1378+
validateOnLoad: false,
1379+
checkSkillFileMtime: true,
1380+
});
1381+
1382+
// Initial discovery
1383+
const initialList = await skills.list();
1384+
expect(initialList).toHaveLength(1);
1385+
expect(initialList[0]!.description).toBe('A test skill for unit testing');
1386+
1387+
// Advance past the staleness check cooldown
1388+
vi.advanceTimersByTime(WorkspaceSkillsImpl.STALENESS_CHECK_COOLDOWN + 100);
1389+
1390+
// Update the skill content (only file mtime changes, not directory)
1391+
const updatedSkillMd = VALID_SKILL_MD.replace('A test skill for unit testing', 'An updated skill description');
1392+
await filesystem.writeFile('skills/my-skill/SKILL.md', updatedSkillMd);
1393+
1394+
// Update only the file mtime, directory stays old
1395+
fileMtime = new Date(Date.now() + 1000);
1396+
1397+
// maybeRefresh should detect the SKILL.md mtime change via direct skill path check
1398+
await skills.maybeRefresh();
1399+
const afterRefresh = await skills.list();
1400+
1401+
expect(afterRefresh).toHaveLength(1);
1402+
expect(afterRefresh[0]!.description).toBe('An updated skill description');
1403+
} finally {
1404+
vi.useRealTimers();
1405+
}
1406+
});
1407+
});
1408+
11011409
describe('read-only mode (SkillSource)', () => {
11021410
// Create a mock read-only source (no write methods)
11031411
function createMockReadOnlySource(files: Record<string, string | Buffer> = {}) {

0 commit comments

Comments
 (0)