mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 22:33:05 -07:00
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:
@@ -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:`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface BaseAgentDefinition<
|
||||
metadata?: {
|
||||
hash?: string;
|
||||
filePath?: string;
|
||||
pluginRoot?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user