feat(core): implement core infrastructure for Agent Teams

- Add TeamDefinition interface to define agent team structure
- Implement TeamLoader for discovering and parsing TEAM.md files
- Implement TeamRegistry for team session management and agent registration
- Integrate TeamRegistry into Config and Storage services
- Add unit tests for team loader and registry
This commit is contained in:
Taylor Mullen
2026-04-01 15:07:50 -07:00
parent 0d7e778e08
commit ef80628b86
8 changed files with 634 additions and 1 deletions
+1 -1
View File
@@ -317,7 +317,7 @@ export class AgentRegistry {
* it will be overwritten, respecting the precedence established by the
* initialization order.
*/
protected async registerAgent<TOutput extends z.ZodTypeAny>(
async registerAgent<TOutput extends z.ZodTypeAny>(
definition: AgentDefinition<TOutput>,
): Promise<void> {
if (definition.kind === 'local') {
+183
View File
@@ -0,0 +1,183 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { loadTeamsFromDirectory } from './teamLoader.js';
describe('teamLoader', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-test-'));
});
afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
async function createTeamStructure(
teamName: string,
teamMdContent: string,
agents: Record<string, string> = {},
) {
const teamPath = path.join(tempDir, teamName);
await fs.mkdir(teamPath, { recursive: true });
await fs.writeFile(path.join(teamPath, 'TEAM.md'), teamMdContent);
if (Object.keys(agents).length > 0) {
const agentsPath = path.join(teamPath, 'agents');
await fs.mkdir(agentsPath, { recursive: true });
for (const [name, content] of Object.entries(agents)) {
await fs.writeFile(path.join(agentsPath, `${name}.md`), content);
}
}
return teamPath;
}
it('should load a valid team with agents', async () => {
await createTeamStructure(
'my-team',
`---
name: my-team
display_name: My Team
description: A great team
---
Team instructions here.`,
{
'agent-1': `---
name: agent-1
description: Agent 1
---
Agent 1 prompt`,
},
);
const result = await loadTeamsFromDirectory(tempDir);
expect(result.teams).toHaveLength(1);
expect(result.errors).toHaveLength(0);
const team = result.teams[0];
expect(team.name).toBe('my-team');
expect(team.displayName).toBe('My Team');
expect(team.description).toBe('A great team');
expect(team.instructions).toBe('Team instructions here.');
expect(team.agents).toHaveLength(1);
expect(team.agents[0].name).toBe('agent-1');
expect(team.metadata?.filePath).toContain('my-team/TEAM.md');
});
it('should skip directories without TEAM.md', async () => {
await fs.mkdir(path.join(tempDir, 'not-a-team'), { recursive: true });
await fs.writeFile(path.join(tempDir, 'not-a-team', 'README.md'), 'test');
const result = await loadTeamsFromDirectory(tempDir);
expect(result.teams).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
it('should handle multiple teams', async () => {
await createTeamStructure(
'team-1',
`---
name: team-1
display_name: Team One
description: First team
---
Instructions 1`,
);
await createTeamStructure(
'team-2',
`---
name: team-2
display_name: Team Two
description: Second team
---
Instructions 2`,
);
const result = await loadTeamsFromDirectory(tempDir);
expect(result.teams).toHaveLength(2);
const names = result.teams.map((t) => t.name).sort();
expect(names).toEqual(['team-1', 'team-2']);
});
it('should capture errors for malformed TEAM.md', async () => {
const teamPath = path.join(tempDir, 'bad-team');
await fs.mkdir(teamPath, { recursive: true });
await fs.writeFile(path.join(teamPath, 'TEAM.md'), 'invalid content');
const result = await loadTeamsFromDirectory(tempDir);
expect(result.teams).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain(
'Missing mandatory YAML frontmatter',
);
});
it('should capture validation errors for TEAM.md', async () => {
await createTeamStructure(
'invalid-team',
`---
name: invalid-team
# missing display_name and description
---
Instructions`,
);
const result = await loadTeamsFromDirectory(tempDir);
expect(result.teams).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Validation failed');
});
it('should load team even if agents subfolder is missing', async () => {
await createTeamStructure(
'no-agents-team',
`---
name: no-agents-team
display_name: No Agents
description: No agents here
---
Instructions`,
);
const result = await loadTeamsFromDirectory(tempDir);
expect(result.teams).toHaveLength(1);
expect(result.teams[0].agents).toHaveLength(0);
});
it('should capture errors from agents subfolder', async () => {
await createTeamStructure(
'bad-agents-team',
`---
name: bad-agents-team
display_name: Bad Agents
description: Team with bad agents
---
Instructions`,
{
'bad-agent': 'invalid agent content',
},
);
const result = await loadTeamsFromDirectory(tempDir);
expect(result.teams).toHaveLength(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].filePath).toContain('agents/bad-agent.md');
});
it('should return empty result if directory does not exist', async () => {
const nonExistentDir = path.join(tempDir, 'void');
const result = await loadTeamsFromDirectory(nonExistentDir);
expect(result.teams).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { load } from 'js-yaml';
import * as fs from 'node:fs/promises';
import { type Dirent } from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { z } from 'zod';
import { type TeamDefinition } from './types.js';
import { AgentLoadError, loadAgentsFromDirectory } from './agentLoader.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
/**
* Result of loading teams from a directory.
*/
export interface TeamLoadResult {
teams: TeamDefinition[];
errors: AgentLoadError[];
}
const nameSchema = z
.string()
.regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug');
const teamSchema = z
.object({
name: nameSchema,
display_name: z.string().min(1),
description: z.string().min(1),
})
.strict();
/**
* Loads all teams from a specific directory.
* Each subdirectory is treated as a potential team.
*
* @param dir Directory path to scan (e.g., .gemini/teams/).
* @returns Object containing successfully loaded teams and any errors.
*/
export async function loadTeamsFromDirectory(
dir: string,
): Promise<TeamLoadResult> {
const result: TeamLoadResult = {
teams: [],
errors: [],
};
let dirEntries: Dirent[];
try {
dirEntries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
// If directory doesn't exist, just return empty
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return result;
}
result.errors.push(
new AgentLoadError(
dir,
`Could not list directory: ${getErrorMessage(error)}`,
),
);
return result;
}
const teamDirs = dirEntries.filter((entry) => entry.isDirectory());
for (const entry of teamDirs) {
const teamPath = path.join(dir, entry.name);
const teamMdPath = path.join(teamPath, 'TEAM.md');
const agentsDirPath = path.join(teamPath, 'agents');
try {
// Check if TEAM.md exists
try {
await fs.access(teamMdPath);
} catch {
// Not a team directory (missing TEAM.md), just skip it
continue;
}
const teamMdContent = await fs.readFile(teamMdPath, 'utf-8');
const hash = crypto
.createHash('sha256')
.update(teamMdContent)
.digest('hex');
// Parse TEAM.md
const match = teamMdContent.match(FRONTMATTER_REGEX);
if (!match) {
throw new AgentLoadError(
teamMdPath,
'Invalid team definition: Missing mandatory YAML frontmatter.',
);
}
const frontmatterStr = match[1];
const instructions = (match[2] || '').trim();
let rawFrontmatter: unknown;
try {
rawFrontmatter = load(frontmatterStr);
} catch (error) {
throw new AgentLoadError(
teamMdPath,
`YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
);
}
const parsedFrontmatter = teamSchema.safeParse(rawFrontmatter);
if (!parsedFrontmatter.success) {
throw new AgentLoadError(
teamMdPath,
`Validation failed:\n${parsedFrontmatter.error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('\n')}`,
);
}
const { name, display_name, description } = parsedFrontmatter.data;
// Load agents from agents/ subfolder
const agentsResult = await loadAgentsFromDirectory(agentsDirPath);
result.errors.push(...agentsResult.errors);
result.teams.push({
name,
displayName: display_name,
description,
instructions,
agents: agentsResult.agents,
metadata: {
hash,
filePath: teamMdPath,
},
});
} catch (error) {
if (error instanceof AgentLoadError) {
result.errors.push(error);
} else {
result.errors.push(
new AgentLoadError(
teamMdPath,
`Unexpected error: ${getErrorMessage(error)}`,
),
);
}
}
}
return result;
}
@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TeamRegistry } from './teamRegistry.js';
import { type Config } from '../config/config.js';
import { type AgentRegistry } from './registry.js';
import { loadTeamsFromDirectory } from './teamLoader.js';
import { type TeamDefinition, type AgentDefinition } from './types.js';
vi.mock('./teamLoader.js', () => ({
loadTeamsFromDirectory: vi.fn(),
}));
describe('TeamRegistry', () => {
let mockConfig: Config;
let mockAgentRegistry: AgentRegistry;
let registry: TeamRegistry;
beforeEach(() => {
mockConfig = {
isAgentsEnabled: vi.fn().mockReturnValue(true),
getFolderTrust: vi.fn().mockReturnValue(false),
isTrustedFolder: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/mock/project'),
getDebugMode: vi.fn().mockReturnValue(false),
storage: {
getProjectTeamsDir: vi
.fn()
.mockReturnValue('/mock/project/.gemini/teams'),
},
} as unknown as Config;
mockAgentRegistry = {
registerAgent: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentRegistry;
registry = new TeamRegistry(mockConfig, mockAgentRegistry);
vi.mocked(loadTeamsFromDirectory).mockReset();
});
it('should load teams and register agents on initialize', async () => {
const mockAgent: AgentDefinition = {
kind: 'local',
name: 'team-agent',
description: 'Agent in a team',
inputConfig: { inputSchema: { type: 'object' } },
} as unknown as AgentDefinition;
const mockTeam: TeamDefinition = {
name: 'test-team',
displayName: 'Test Team',
description: 'A team for testing',
instructions: 'Do tests.',
agents: [mockAgent],
};
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
teams: [mockTeam],
errors: [],
});
await registry.initialize();
expect(registry.getAllTeams()).toHaveLength(1);
expect(registry.getTeam('test-team')).toEqual(mockTeam);
expect(mockAgentRegistry.registerAgent).toHaveBeenCalledWith(mockAgent);
});
it('should not load teams if agents are disabled', async () => {
mockConfig.isAgentsEnabled.mockReturnValue(false);
await registry.initialize();
expect(loadTeamsFromDirectory).not.toHaveBeenCalled();
expect(registry.getAllTeams()).toHaveLength(0);
});
it('should skip project teams in untrusted folder', async () => {
mockConfig.getFolderTrust.mockReturnValue(true);
mockConfig.isTrustedFolder.mockReturnValue(false);
await registry.initialize();
expect(loadTeamsFromDirectory).not.toHaveBeenCalled();
});
it('should manage active team', async () => {
const mockTeam: TeamDefinition = {
name: 'active-team',
displayName: 'Active Team',
description: 'The active one',
instructions: 'Lead.',
agents: [],
};
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
teams: [mockTeam],
errors: [],
});
await registry.initialize();
expect(registry.getActiveTeam()).toBeUndefined();
registry.setActiveTeam('active-team');
expect(registry.getActiveTeam()).toEqual(mockTeam);
expect(() => registry.setActiveTeam('non-existent')).toThrow(
'Team not found',
);
});
it('should reload teams', async () => {
vi.mocked(loadTeamsFromDirectory).mockResolvedValue({
teams: [],
errors: [],
});
await registry.initialize();
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(1);
await registry.reload();
expect(loadTeamsFromDirectory).toHaveBeenCalledTimes(2);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type TeamDefinition } from './types.js';
import { type Config } from '../config/config.js';
import { type AgentRegistry } from './registry.js';
import { loadTeamsFromDirectory } from './teamLoader.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
/**
* Manages the discovery, loading, and active state of Agent Teams.
*/
export class TeamRegistry {
private readonly teams = new Map<string, TeamDefinition>();
private activeTeamName?: string;
constructor(
private readonly config: Config,
private readonly agentRegistry: AgentRegistry,
) {}
/**
* Discovers and loads teams from the project's .gemini/teams/ directory.
*/
async initialize(): Promise<void> {
await this.loadTeams();
}
/**
* Clears the current registry and re-scans for teams.
*/
async reload(): Promise<void> {
await this.loadTeams();
}
private async loadTeams(): Promise<void> {
this.teams.clear();
if (!this.config.isAgentsEnabled()) {
return;
}
const folderTrustEnabled = this.config.getFolderTrust();
const isTrustedFolder = this.config.isTrustedFolder();
if (folderTrustEnabled && !isTrustedFolder) {
debugLogger.log(
'[TeamRegistry] Skipping project teams due to untrusted folder.',
);
coreEvents.emitFeedback(
'info',
'Skipping project teams due to untrusted folder. To enable, ensure that the project root is trusted.',
);
return;
}
const projectTeamsDir = this.config.storage.getProjectTeamsDir();
const loadResult = await loadTeamsFromDirectory(projectTeamsDir);
for (const error of loadResult.errors) {
debugLogger.warn(`[TeamRegistry] Error loading team: ${error.message}`);
coreEvents.emitFeedback('error', `Team loading error: ${error.message}`);
}
for (const team of loadResult.teams) {
this.teams.set(team.name, team);
// Register team agents in the global AgentRegistry so they are available as SubagentTools
for (const agent of team.agents) {
try {
await this.agentRegistry.registerAgent(agent);
} catch (e) {
debugLogger.warn(
`[TeamRegistry] Error registering agent "${agent.name}" from team "${team.name}":`,
e,
);
coreEvents.emitFeedback(
'error',
`Error registering agent "${agent.name}" from team "${team.name}": ${e instanceof Error ? e.message : String(e)}`,
);
}
}
}
if (this.config.getDebugMode()) {
debugLogger.log(`[TeamRegistry] Loaded with ${this.teams.size} teams.`);
}
}
/**
* Returns all loaded teams.
*/
getAllTeams(): TeamDefinition[] {
return Array.from(this.teams.values());
}
/**
* Sets the current active team.
* @param name The slug (name) of the team to activate.
* @throws Error if the team is not found.
*/
setActiveTeam(name: string): void {
if (this.teams.has(name)) {
this.activeTeamName = name;
} else {
throw new Error(`Team not found: ${name}`);
}
}
/**
* Returns the currently active team definition, if any.
*/
getActiveTeam(): TeamDefinition | undefined {
return this.activeTeamName
? this.teams.get(this.activeTeamName)
: undefined;
}
/**
* Returns a team definition by name.
*/
getTeam(name: string): TeamDefinition | undefined {
return this.teams.get(name);
}
}
+23
View File
@@ -340,3 +340,26 @@ export interface RunConfig {
*/
maxTurns?: number;
}
/**
* Represents a team of agents orchestrated by a set of instructions.
*/
export interface TeamDefinition {
/** The directory name (slug) of the team. */
name: string;
/** The human-readable name of the team. */
displayName: string;
/** A short description of the team's purpose. */
description: string;
/** Orchestration instructions for the top-level agent. */
instructions: string;
/** The list of agents that comprise the team. */
agents: AgentDefinition[];
/** Optional metadata for the team definition. */
metadata?: {
/** SHA-256 hash of the TEAM.md content. */
hash?: string;
/** Absolute path to the TEAM.md file. */
filePath?: string;
};
}
+9
View File
@@ -150,6 +150,7 @@ import {
type Experiments,
} from '../code_assist/experiments/experiments.js';
import { AgentRegistry } from '../agents/registry.js';
import { TeamRegistry } from '../agents/teamRegistry.js';
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
import { setGlobalProxy } from '../utils/fetch.js';
import { SubagentTool } from '../agents/subagent-tool.js';
@@ -752,6 +753,7 @@ export class Config implements McpContext, AgentLoopContext {
private _promptRegistry!: PromptRegistry;
private _resourceRegistry!: ResourceRegistry;
private agentRegistry!: AgentRegistry;
private teamRegistry!: TeamRegistry;
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
private skillManager!: SkillManager;
private _sessionId: string;
@@ -1431,6 +1433,9 @@ export class Config implements McpContext, AgentLoopContext {
this.agentRegistry = new AgentRegistry(this);
await this.agentRegistry.initialize();
this.teamRegistry = new TeamRegistry(this, this.agentRegistry);
await this.teamRegistry.initialize();
coreEvents.on(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed);
this._toolRegistry = await this.createToolRegistry();
@@ -2026,6 +2031,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.agentRegistry;
}
getTeamRegistry(): TeamRegistry {
return this.teamRegistry;
}
getAcknowledgedAgentsService(): AcknowledgedAgentsService {
return this.acknowledgedAgentsService;
}
+4
View File
@@ -291,6 +291,10 @@ export class Storage {
return path.join(this.getGeminiDir(), 'agents');
}
getProjectTeamsDir(): string {
return path.join(this.getGeminiDir(), 'teams');
}
getProjectTempCheckpointsDir(): string {
return path.join(this.getProjectTempDir(), 'checkpoints');
}