feat: implement Open Plugins agents support

- Add pluginRoot to AgentDefinition metadata
- Implement ${PLUGIN_ROOT} expansion in markdownToAgentDefinition
- Automatically discover and namespace agents in Open Plugins
- Update ExtensionManager to pass plugin root during agent loading
- Display sub-agents in extensions list output

Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1594
This commit is contained in:
Taylor Mullen
2026-03-23 22:50:12 -07:00
parent f7413564ac
commit 1a3741d2aa
6 changed files with 207 additions and 23 deletions
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
const pluginJson = (name: string) => `{
"name": "${name}",
"version": "1.0.0",
"description": "A test open plugin"
}`;
const agentMarkdown = (name: string, description: string) => `---
name: ${name}
description: ${description}
mcp_servers:
test-server:
command: node
args: ["\${PLUGIN_ROOT}/server.js"]
---
You are ${name}. My root is \${PLUGIN_ROOT}.`;
describe('Open Plugin agents', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('discovers, namespaces, and expands PLUGIN_ROOT for Open Plugin agents', async () => {
rig.setup('open-plugin agents test');
const pluginDir = join(rig.testDir!, 'test-plugin');
mkdirSync(pluginDir, { recursive: true });
mkdirSync(join(pluginDir, 'agents'), { recursive: true });
const pluginName = 'test-plugin';
writeFileSync(join(pluginDir, 'plugin.json'), pluginJson(pluginName));
writeFileSync(
join(pluginDir, 'agents', 'researcher.md'),
agentMarkdown('researcher', 'A researcher in ${PLUGIN_ROOT}'),
);
// Install the plugin
await rig.runCommand(['extensions', 'install', pluginDir], {
stdin: 'y\n',
});
// List extensions to verify the agent is registered and expanded
const listResult = await rig.runCommand(['extensions', 'list']);
// Check namespacing
expect(listResult).toContain(`${pluginName}:researcher`);
// Check expansion in description (via toOutputString)
const installedPathMatch = listResult.match(/Path: (.*)/);
const installedPath = installedPathMatch
? installedPathMatch[1].trim()
: '';
expect(listResult).toContain(`A researcher in ${installedPath}`);
// Check expansion in prompt by "running" the agent or checking its internal state
// For integration test, we can try to use the agent in a non-interactive mode
// but that might be slow/complex.
// Given we verified description expansion, and core tests verified prompt expansion,
// this should be sufficient.
});
});
@@ -997,6 +997,7 @@ Would you like to attempt to install via "git clone" instead?`,
const agentLoadResult = await loadAgentsFromDirectory(
path.join(effectiveExtensionPath, 'agents'),
effectiveExtensionPath,
);
agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({
...recursivelyHydrateStrings(agent, hydrationContext),
@@ -1184,6 +1185,12 @@ Would you like to attempt to install via "git clone" instead?`,
output += `\n ${skill.name}: ${skill.description}`;
});
}
if (extension.agents && extension.agents.length > 0) {
output += `\n Sub-agents:`;
extension.agents.forEach((agent) => {
output += `\n ${agent.name}: ${agent.description}`;
});
}
const resolvedSettings = extension.resolvedSettings;
if (resolvedSettings && resolvedSettings.length > 0) {
output += `\n Settings:`;
+30 -1
View File
@@ -9,6 +9,7 @@ import * as path from 'node:path';
import { z } from 'zod';
import {
loadSkillsFromDir,
loadAgentsFromDirectory,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
type MCPServerConfig,
@@ -246,6 +247,12 @@ export async function createOpenPlugin(
hydrationContext,
);
const agents = await resolvePluginAgents(
pluginDir,
config.name,
hydrationContext,
);
return {
name: config.name,
version: config.version,
@@ -264,7 +271,7 @@ export async function createOpenPlugin(
settings: undefined,
resolvedSettings: undefined,
skills,
agents: undefined,
agents,
themes: undefined,
};
}
@@ -290,3 +297,25 @@ async function resolvePluginSkills(
extensionName: pluginName,
}));
}
/**
* Discovers and namespaces agents for an Open Plugin.
*/
async function resolvePluginAgents(
pluginDir: string,
pluginName: string,
hydrationContext: Record<string, string>,
): Promise<GeminiCLIExtension['agents']> {
const agentsDir = path.join(pluginDir, 'agents');
const agentLoadResult = await loadAgentsFromDirectory(agentsDir, pluginDir);
if (agentLoadResult.agents.length === 0) {
return undefined;
}
return agentLoadResult.agents.map((agent) => ({
...recursivelyHydrateStrings(agent, hydrationContext),
name: `${pluginName}:${agent.name}`,
extensionName: pluginName,
}));
}
@@ -343,6 +343,32 @@ Body`);
expect(result.modelConfig.model).toBe('auto');
});
it('should expand ${PLUGIN_ROOT} in agent definition', () => {
const markdown = {
kind: 'local' as const,
name: 'expansion-agent',
description: 'An agent in ${PLUGIN_ROOT}',
system_prompt: 'You are at ${PLUGIN_ROOT}',
mcp_servers: {
'test-server': {
command: 'node',
args: ['${PLUGIN_ROOT}/server.js'],
},
},
};
const pluginRoot = '/abs/path/to/plugin';
const result = markdownToAgentDefinition(markdown, {
pluginRoot,
}) as LocalAgentDefinition;
expect(result.description).toBe(`An agent in ${pluginRoot}`);
expect(result.promptConfig.systemPrompt).toBe(`You are at ${pluginRoot}`);
expect(result.mcpServers!['test-server'].args).toEqual([
`${pluginRoot}/server.js`,
]);
});
it('should convert remote agent definition', () => {
const markdown = {
kind: 'remote' as const,
+69 -22
View File
@@ -489,17 +489,55 @@ function convertFrontmatterAuthToConfig(
}
}
/**
* Recursively expands ${PLUGIN_ROOT} in strings, arrays, and objects.
*/
function recursivelyExpandPluginRoot<T>(obj: T, pluginRoot: string): T {
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.replace(/\${PLUGIN_ROOT}/g, pluginRoot) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
recursivelyExpandPluginRoot(item, pluginRoot),
) as unknown as T;
}
if (typeof obj === 'object' && obj !== null) {
const newObj: Record<string, unknown> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = recursivelyExpandPluginRoot(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(obj as Record<string, unknown>)[key],
pluginRoot,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return newObj as T;
}
return obj;
}
/**
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
*
* @param markdown The parsed Markdown/Frontmatter definition.
* @param metadata Optional metadata including hash and file path.
* @param metadata Optional metadata including hash, file path, and plugin root.
* @returns The internal AgentDefinition.
*/
export function markdownToAgentDefinition(
markdown: FrontmatterAgentDefinition,
metadata?: { hash?: string; filePath?: string },
metadata?: { hash?: string; filePath?: string; pluginRoot?: string },
): AgentDefinition {
const pluginRoot = metadata?.pluginRoot;
const processedMarkdown =
pluginRoot !== undefined
? recursivelyExpandPluginRoot(markdown, pluginRoot)
: markdown;
const inputConfig = {
inputSchema: {
type: 'object',
@@ -514,15 +552,15 @@ export function markdownToAgentDefinition(
},
};
if (markdown.kind === 'remote') {
if (processedMarkdown.kind === 'remote') {
return {
kind: 'remote',
name: markdown.name,
description: markdown.description || '',
displayName: markdown.display_name,
agentCardUrl: markdown.agent_card_url,
auth: markdown.auth
? convertFrontmatterAuthToConfig(markdown.auth)
name: processedMarkdown.name,
description: processedMarkdown.description || '',
displayName: processedMarkdown.display_name,
agentCardUrl: processedMarkdown.agent_card_url,
auth: processedMarkdown.auth
? convertFrontmatterAuthToConfig(processedMarkdown.auth)
: undefined,
inputConfig,
metadata,
@@ -530,11 +568,13 @@ export function markdownToAgentDefinition(
}
// If a model is specified, use it. Otherwise, inherit
const modelName = markdown.model || 'inherit';
const modelName = processedMarkdown.model || 'inherit';
const mcpServers: Record<string, MCPServerConfig> = {};
if (markdown.kind === 'local' && markdown.mcp_servers) {
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
if (processedMarkdown.kind === 'local' && processedMarkdown.mcp_servers) {
for (const [name, config] of Object.entries(
processedMarkdown.mcp_servers,
)) {
mcpServers[name] = new MCPServerConfig(
config.command,
config.args,
@@ -556,27 +596,28 @@ export function markdownToAgentDefinition(
return {
kind: 'local',
name: markdown.name,
description: markdown.description,
displayName: markdown.display_name,
name: processedMarkdown.name,
description: processedMarkdown.description,
displayName: processedMarkdown.display_name,
promptConfig: {
systemPrompt: markdown.system_prompt,
systemPrompt: processedMarkdown.system_prompt,
query: '${query}',
},
modelConfig: {
model: modelName,
generateContentConfig: {
temperature: markdown.temperature ?? 1,
temperature: processedMarkdown.temperature ?? 1,
topP: 0.95,
},
},
runConfig: {
maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS,
maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES,
maxTurns: processedMarkdown.max_turns ?? DEFAULT_MAX_TURNS,
maxTimeMinutes:
processedMarkdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES,
},
toolConfig: markdown.tools
toolConfig: processedMarkdown.tools
? {
tools: markdown.tools,
tools: processedMarkdown.tools,
}
: undefined,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
@@ -591,10 +632,12 @@ export function markdownToAgentDefinition(
* Supported extensions: .md
*
* @param dir Directory path to scan.
* @param pluginRoot Optional plugin root path for variable expansion.
* @returns Object containing successfully loaded agents and any errors.
*/
export async function loadAgentsFromDirectory(
dir: string,
pluginRoot?: string,
): Promise<AgentLoadResult> {
const result: AgentLoadResult = {
agents: [],
@@ -634,7 +677,11 @@ export async function loadAgentsFromDirectory(
const hash = crypto.createHash('sha256').update(content).digest('hex');
const agentDefs = await parseAgentMarkdown(filePath, content);
for (const def of agentDefs) {
const agent = markdownToAgentDefinition(def, { hash, filePath });
const agent = markdownToAgentDefinition(def, {
hash,
filePath,
pluginRoot,
});
result.agents.push(agent);
}
} catch (error) {
+1
View File
@@ -129,6 +129,7 @@ export interface BaseAgentDefinition<
metadata?: {
hash?: string;
filePath?: string;
pluginRoot?: string;
};
}