Agent Skills: Implement Core Skill Infrastructure & Tiered Discovery (#15698)

This commit is contained in:
N. Taylor Mullen
2025-12-30 13:35:52 -08:00
committed by GitHub
parent ec11b8afbf
commit de1233b8ca
19 changed files with 1209 additions and 3 deletions
+24 -1
View File
@@ -94,6 +94,7 @@ import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js';
import { getExperiments } from '../code_assist/experiments/experiments.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { SkillManager } from '../services/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import { ApprovalMode } from '../policy/types.js';
@@ -348,6 +349,8 @@ export interface ConfigParameters {
};
previewFeatures?: boolean;
enableAgents?: boolean;
skillsSupport?: boolean;
disabledSkills?: string[];
experimentalJitContext?: boolean;
onModelChange?: (model: string) => void;
}
@@ -363,6 +366,7 @@ export class Config {
private promptRegistry!: PromptRegistry;
private resourceRegistry!: ResourceRegistry;
private agentRegistry!: AgentRegistry;
private skillManager!: SkillManager;
private sessionId: string;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
@@ -475,6 +479,8 @@ export class Config {
private readonly onModelChange: ((model: string) => void) | undefined;
private readonly enableAgents: boolean;
private readonly skillsSupport: boolean;
private readonly disabledSkills: string[];
private readonly experimentalJitContext: boolean;
private contextManager?: ContextManager;
@@ -542,9 +548,11 @@ export class Config {
this.model = params.model;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false;
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.skillsSupport = params.skillsSupport ?? false;
this.disabledSkills = params.disabledSkills ?? [];
this.modelAvailabilityService = new ModelAvailabilityService();
this.previewFeatures = params.previewFeatures ?? undefined;
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
@@ -624,6 +632,7 @@ export class Config {
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
});
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
this.skillManager = new SkillManager();
this.outputSettings = {
format: params.output?.format ?? OutputFormat.TEXT,
};
@@ -721,6 +730,12 @@ export class Config {
]);
initMcpHandle?.end();
// Discover skills if enabled
if (this.skillsSupport) {
await this.getSkillManager().discoverSkills(this.storage);
this.getSkillManager().setDisabledSkills(this.disabledSkills);
}
// Initialize hook system if enabled
if (this.enableHooks) {
this.hookSystem = new HookSystem(this);
@@ -973,6 +988,10 @@ export class Config {
return this.promptRegistry;
}
getSkillManager(): SkillManager {
return this.skillManager;
}
getResourceRegistry(): ResourceRegistry {
return this.resourceRegistry;
}
@@ -1478,6 +1497,10 @@ export class Config {
);
}
isSkillsSupportEnabled(): boolean {
return this.skillsSupport;
}
isInteractive(): boolean {
return this.interactive;
}
+10
View File
@@ -45,6 +45,16 @@ describe('Storage additional helpers', () => {
expect(storage.getProjectCommandsDir()).toBe(expected);
});
it('getUserSkillsDir returns ~/.gemini/skills', () => {
const expected = path.join(os.homedir(), GEMINI_DIR, 'skills');
expect(Storage.getUserSkillsDir()).toBe(expected);
});
it('getProjectSkillsDir returns project/.gemini/skills', () => {
const expected = path.join(projectRoot, GEMINI_DIR, 'skills');
expect(storage.getProjectSkillsDir()).toBe(expected);
});
it('getUserAgentsDir returns ~/.gemini/agents', () => {
const expected = path.join(os.homedir(), GEMINI_DIR, 'agents');
expect(Storage.getUserAgentsDir()).toBe(expected);
+8
View File
@@ -50,6 +50,10 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), 'commands');
}
static getUserSkillsDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'skills');
}
static getGlobalMemoryFilePath(): string {
return path.join(Storage.getGlobalGeminiDir(), 'memory.md');
}
@@ -127,6 +131,10 @@ export class Storage {
return path.join(this.getGeminiDir(), 'commands');
}
getProjectSkillsDir(): string {
return path.join(this.getGeminiDir(), 'skills');
}
getProjectAgentsDir(): string {
return path.join(this.getGeminiDir(), 'agents');
}
@@ -0,0 +1,237 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { SkillManager } from './skillManager.js';
import { Storage } from '../config/storage.js';
describe('SkillManager', () => {
let testRootDir: string;
beforeEach(async () => {
testRootDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'skill-manager-test-'),
);
});
afterEach(async () => {
await fs.rm(testRootDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('should discover skills with valid SKILL.md and frontmatter', async () => {
const skillDir = path.join(testRootDir, 'my-skill');
await fs.mkdir(skillDir, { recursive: true });
const skillFile = path.join(skillDir, 'SKILL.md');
await fs.writeFile(
skillFile,
`---
name: my-skill
description: A test skill
---
# Instructions
Do something.
`,
);
const service = new SkillManager();
const skills = await service.discoverSkillsInternal([testRootDir]);
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('my-skill');
expect(skills[0].description).toBe('A test skill');
expect(skills[0].location).toBe(skillFile);
expect(skills[0].body).toBe('# Instructions\nDo something.');
});
it('should ignore directories without SKILL.md', async () => {
const notASkillDir = path.join(testRootDir, 'not-a-skill');
await fs.mkdir(notASkillDir, { recursive: true });
const service = new SkillManager();
const skills = await service.discoverSkillsInternal([testRootDir]);
expect(skills).toHaveLength(0);
});
it('should ignore SKILL.md without valid frontmatter', async () => {
const skillDir = path.join(testRootDir, 'invalid-skill');
await fs.mkdir(skillDir, { recursive: true });
const skillFile = path.join(skillDir, 'SKILL.md');
await fs.writeFile(skillFile, '# No frontmatter here');
const service = new SkillManager();
const skills = await service.discoverSkillsInternal([testRootDir]);
expect(skills).toHaveLength(0);
});
it('should ignore SKILL.md with missing required frontmatter fields', async () => {
const skillDir = path.join(testRootDir, 'missing-fields');
await fs.mkdir(skillDir, { recursive: true });
const skillFile = path.join(skillDir, 'SKILL.md');
await fs.writeFile(
skillFile,
`---
name: missing-fields
---
`,
);
const service = new SkillManager();
const skills = await service.discoverSkillsInternal([testRootDir]);
expect(skills).toHaveLength(0);
});
it('should handle multiple search paths', async () => {
const path1 = path.join(testRootDir, 'path1');
const path2 = path.join(testRootDir, 'path2');
await fs.mkdir(path1, { recursive: true });
await fs.mkdir(path2, { recursive: true });
const skill1Dir = path.join(path1, 'skill1');
await fs.mkdir(skill1Dir, { recursive: true });
await fs.writeFile(
path.join(skill1Dir, 'SKILL.md'),
`---
name: skill1
description: Skill 1
---
`,
);
const skill2Dir = path.join(path2, 'skill2');
await fs.mkdir(skill2Dir, { recursive: true });
await fs.writeFile(
path.join(skill2Dir, 'SKILL.md'),
`---
name: skill2
description: Skill 2
---
`,
);
const service = new SkillManager();
const skills = await service.discoverSkillsInternal([path1, path2]);
expect(skills).toHaveLength(2);
expect(skills.map((s) => s.name).sort()).toEqual(['skill1', 'skill2']);
});
it('should deduplicate skills by name (last wins)', async () => {
const path1 = path.join(testRootDir, 'path1');
const path2 = path.join(testRootDir, 'path2');
await fs.mkdir(path1, { recursive: true });
await fs.mkdir(path2, { recursive: true });
await fs.mkdir(path.join(path1, 'skill'), { recursive: true });
await fs.writeFile(
path.join(path1, 'skill', 'SKILL.md'),
`---
name: same-name
description: First
---
`,
);
await fs.mkdir(path.join(path2, 'skill'), { recursive: true });
await fs.writeFile(
path.join(path2, 'skill', 'SKILL.md'),
`---
name: same-name
description: Second
---
`,
);
const service = new SkillManager();
// In our tiered discovery logic, we call discoverSkillsInternal for each tier
// and then add them with precedence.
const skills1 = await service.discoverSkillsInternal([path1]);
service['addSkillsWithPrecedence'](skills1);
const skills2 = await service.discoverSkillsInternal([path2]);
service['addSkillsWithPrecedence'](skills2);
const skills = service.getSkills();
expect(skills).toHaveLength(1);
expect(skills[0].description).toBe('Second');
});
it('should discover skills from Storage with project precedence', async () => {
const userDir = path.join(testRootDir, 'user');
const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(path.join(userDir, 'skill-a'), { recursive: true });
await fs.mkdir(path.join(projectDir, 'skill-a'), { recursive: true });
await fs.writeFile(
path.join(userDir, 'skill-a', 'SKILL.md'),
`---
name: skill-a
description: user-desc
---
`,
);
await fs.writeFile(
path.join(projectDir, 'skill-a', 'SKILL.md'),
`---
name: skill-a
description: project-desc
---
`,
);
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir);
const storage = new Storage('/dummy');
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir);
const service = new SkillManager();
await service.discoverSkills(storage);
const skills = service.getSkills();
expect(skills).toHaveLength(1);
expect(skills[0].description).toBe('project-desc');
});
it('should filter disabled skills in getSkills but not in getAllSkills', async () => {
const skill1Dir = path.join(testRootDir, 'skill1');
const skill2Dir = path.join(testRootDir, 'skill2');
await fs.mkdir(skill1Dir, { recursive: true });
await fs.mkdir(skill2Dir, { recursive: true });
await fs.writeFile(
path.join(skill1Dir, 'SKILL.md'),
`---
name: skill1
description: desc1
---
`,
);
await fs.writeFile(
path.join(skill2Dir, 'SKILL.md'),
`---
name: skill2
description: desc2
---
`,
);
const service = new SkillManager();
const discovered = await service.discoverSkillsInternal([testRootDir]);
service['addSkillsWithPrecedence'](discovered);
service.setDisabledSkills(['skill1']);
expect(service.getSkills()).toHaveLength(1);
expect(service.getSkills()[0].name).toBe('skill2');
expect(service.getAllSkills()).toHaveLength(2);
expect(
service.getAllSkills().find((s) => s.name === 'skill1')?.disabled,
).toBe(true);
});
});
+197
View File
@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { glob } from 'glob';
import yaml from 'js-yaml';
import { debugLogger } from '../utils/debugLogger.js';
import { Storage } from '../config/storage.js';
export interface SkillMetadata {
name: string;
description: string;
location: string;
body: string;
disabled?: boolean;
}
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/;
export class SkillManager {
private skills: SkillMetadata[] = [];
private activeSkillNames: Set<string> = new Set();
/**
* Clears all discovered skills.
*/
clearSkills(): void {
this.skills = [];
}
/**
* Discovers skills from standard user and project locations.
* Project skills take precedence over user skills.
*/
async discoverSkills(storage: Storage): Promise<void> {
this.clearSkills();
// User skills first
const userPaths = [Storage.getUserSkillsDir()];
const userSkills = await this.discoverSkillsInternal(userPaths);
this.addSkillsWithPrecedence(userSkills);
// Project skills second (overwrites user skills with same name)
const projectPaths = [storage.getProjectSkillsDir()];
const projectSkills = await this.discoverSkillsInternal(projectPaths);
this.addSkillsWithPrecedence(projectSkills);
}
private addSkillsWithPrecedence(newSkills: SkillMetadata[]): void {
const skillMap = new Map<string, SkillMetadata>();
for (const skill of [...this.skills, ...newSkills]) {
skillMap.set(skill.name, skill);
}
this.skills = Array.from(skillMap.values());
}
/**
* Discovered skills in the provided paths and adds them to the manager.
* Internal helper for tiered discovery.
*/
async discoverSkillsInternal(paths: string[]): Promise<SkillMetadata[]> {
const discoveredSkills: SkillMetadata[] = [];
const seenLocations = new Set(this.skills.map((s) => s.location));
for (const searchPath of paths) {
try {
const absoluteSearchPath = path.resolve(searchPath);
debugLogger.debug(`Discovering skills in: ${absoluteSearchPath}`);
const stats = await fs.stat(absoluteSearchPath).catch(() => null);
if (!stats || !stats.isDirectory()) {
debugLogger.debug(
`Search path is not a directory: ${absoluteSearchPath}`,
);
continue;
}
const skillFiles = await glob('*/SKILL.md', {
cwd: absoluteSearchPath,
absolute: true,
nodir: true,
});
debugLogger.debug(
`Found ${skillFiles.length} potential skill files in ${absoluteSearchPath}`,
);
for (const skillFile of skillFiles) {
if (seenLocations.has(skillFile)) {
continue;
}
const metadata = await this.parseSkillFile(skillFile);
if (metadata) {
debugLogger.debug(
`Discovered skill: ${metadata.name} at ${skillFile}`,
);
discoveredSkills.push(metadata);
seenLocations.add(skillFile);
}
}
} catch (error) {
debugLogger.log(`Error discovering skills in ${searchPath}:`, error);
}
}
return discoveredSkills;
}
/**
* Returns the list of enabled discovered skills.
*/
getSkills(): SkillMetadata[] {
return this.skills.filter((s) => !s.disabled);
}
/**
* Returns all discovered skills, including disabled ones.
*/
getAllSkills(): SkillMetadata[] {
return this.skills;
}
/**
* Filters discovered skills by name.
*/
filterSkills(predicate: (skill: SkillMetadata) => boolean): void {
this.skills = this.skills.filter(predicate);
}
/**
* Sets the list of disabled skill names.
*/
setDisabledSkills(disabledNames: string[]): void {
for (const skill of this.skills) {
skill.disabled = disabledNames.includes(skill.name);
}
}
/**
* Reads the full content (metadata + body) of a skill by name.
*/
getSkill(name: string): SkillMetadata | null {
return this.skills.find((s) => s.name === name) ?? null;
}
/**
* Activates a skill by name.
*/
activateSkill(name: string): void {
this.activeSkillNames.add(name);
}
/**
* Checks if a skill is active.
*/
isSkillActive(name: string): boolean {
return this.activeSkillNames.has(name);
}
private async parseSkillFile(
filePath: string,
): Promise<SkillMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const match = content.match(FRONTMATTER_REGEX);
if (!match) {
return null;
}
// Use yaml.load() which is safe in js-yaml v4.
const frontmatter = yaml.load(match[1]);
if (!frontmatter || typeof frontmatter !== 'object') {
return null;
}
const { name, description } = frontmatter as Record<string, unknown>;
if (typeof name !== 'string' || typeof description !== 'string') {
return null;
}
return {
name,
description,
location: filePath,
body: match[2].trim(),
};
} catch (error) {
debugLogger.log(`Error parsing skill file ${filePath}:`, error);
return null;
}
}
}