feat(core): add foundation for subagent tool isolation (#22708)

This commit is contained in:
AK
2026-03-16 20:54:33 -07:00
committed by GitHub
parent abe83fce0b
commit 695bcaea0d
9 changed files with 203 additions and 9 deletions
@@ -22,6 +22,25 @@ describe('NewAgentsNotification', () => {
{
name: 'Agent B',
description: 'Description B',
kind: 'local' as const,
inputConfig: { inputSchema: {} },
promptConfig: {},
modelConfig: {},
runConfig: {},
mcpServers: {
github: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
},
postgres: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-postgres'],
},
},
},
{
name: 'Agent C',
description: 'Description C',
kind: 'remote' as const,
agentCardUrl: '',
inputConfig: { inputSchema: {} },
@@ -80,16 +80,35 @@ export const NewAgentsNotification = ({
borderStyle="single"
padding={1}
>
{displayAgents.map((agent) => (
<Box key={agent.name}>
<Box flexShrink={0}>
<Text bold color={theme.text.primary}>
- {agent.name}:{' '}
</Text>
{displayAgents.map((agent) => {
const mcpServers =
agent.kind === 'local' ? agent.mcpServers : undefined;
const hasMcpServers =
mcpServers && Object.keys(mcpServers).length > 0;
return (
<Box key={agent.name} flexDirection="column">
<Box>
<Box flexShrink={0}>
<Text bold color={theme.text.primary}>
- {agent.name}:{' '}
</Text>
</Box>
<Text color={theme.text.secondary}>
{' '}
{agent.description}
</Text>
</Box>
{hasMcpServers && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
(Includes MCP servers:{' '}
{Object.keys(mcpServers).join(', ')})
</Text>
</Box>
)}
</Box>
<Text color={theme.text.secondary}> {agent.description}</Text>
</Box>
))}
);
})}
{remaining > 0 && (
<Text color={theme.text.secondary}>
... and {remaining} more.
@@ -10,6 +10,8 @@ exports[`NewAgentsNotification > renders agent list 1`] = `
│ │ │ │
│ │ - Agent A: Description A │ │
│ │ - Agent B: Description B │ │
│ │ (Includes MCP servers: github, postgres) │ │
│ │ - Agent C: Description C │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
@@ -81,6 +81,33 @@ System prompt content.`);
});
});
it('should parse frontmatter with mcp_servers', async () => {
const filePath = await writeAgentMarkdown(`---
name: mcp-agent
description: An agent with MCP servers
mcp_servers:
test-server:
command: node
args: [server.js]
include_tools: [tool1, tool2]
---
System prompt content.`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: 'mcp-agent',
description: 'An agent with MCP servers',
mcp_servers: {
'test-server': {
command: 'node',
args: ['server.js'],
include_tools: ['tool1', 'tool2'],
},
},
});
});
it('should throw AgentLoadError if frontmatter is missing', async () => {
const filePath = await writeAgentMarkdown(`Just some markdown content.`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
@@ -274,6 +301,33 @@ Body`);
expect(result.modelConfig.model).toBe(GEMINI_MODEL_ALIAS_PRO);
});
it('should convert mcp_servers in local agent', () => {
const markdown = {
kind: 'local' as const,
name: 'mcp-agent',
description: 'An agent with MCP servers',
mcp_servers: {
'test-server': {
command: 'node',
args: ['server.js'],
include_tools: ['tool1'],
},
},
system_prompt: 'prompt',
};
const result = markdownToAgentDefinition(
markdown,
) as LocalAgentDefinition;
expect(result.kind).toBe('local');
expect(result.mcpServers).toBeDefined();
expect(result.mcpServers!['test-server']).toMatchObject({
command: 'node',
args: ['server.js'],
includeTools: ['tool1'],
});
});
it('should pass through unknown model names (e.g. auto)', () => {
const markdown = {
kind: 'local' as const,
+60
View File
@@ -16,6 +16,7 @@ import {
DEFAULT_MAX_TIME_MINUTES,
} from './types.js';
import type { A2AAuthConfig } from './auth-provider/types.js';
import { MCPServerConfig } from '../config/config.js';
import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
@@ -28,11 +29,29 @@ interface FrontmatterBaseAgentDefinition {
display_name?: string;
}
interface FrontmatterMCPServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
http_url?: string;
headers?: Record<string, string>;
tcp?: string;
type?: 'sse' | 'http';
timeout?: number;
trust?: boolean;
description?: string;
include_tools?: string[];
exclude_tools?: string[];
}
interface FrontmatterLocalAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'local';
description: string;
tools?: string[];
mcp_servers?: Record<string, FrontmatterMCPServerConfig>;
system_prompt: string;
model?: string;
temperature?: number;
@@ -100,6 +119,23 @@ const nameSchema = z
.string()
.regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug');
const mcpServerSchema = z.object({
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
cwd: z.string().optional(),
url: z.string().optional(),
http_url: z.string().optional(),
headers: z.record(z.string()).optional(),
tcp: z.string().optional(),
type: z.enum(['sse', 'http']).optional(),
timeout: z.number().optional(),
trust: z.boolean().optional(),
description: z.string().optional(),
include_tools: z.array(z.string()).optional(),
exclude_tools: z.array(z.string()).optional(),
});
const localAgentSchema = z
.object({
kind: z.literal('local').optional().default('local'),
@@ -115,6 +151,7 @@ const localAgentSchema = z
}),
)
.optional(),
mcp_servers: z.record(mcpServerSchema).optional(),
model: z.string().optional(),
temperature: z.number().optional(),
max_turns: z.number().int().positive().optional(),
@@ -495,6 +532,28 @@ export function markdownToAgentDefinition(
// If a model is specified, use it. Otherwise, inherit
const modelName = markdown.model || 'inherit';
const mcpServers: Record<string, MCPServerConfig> = {};
if (markdown.kind === 'local' && markdown.mcp_servers) {
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
mcpServers[name] = new MCPServerConfig(
config.command,
config.args,
config.env,
config.cwd,
config.url,
config.http_url,
config.headers,
config.tcp,
config.type,
config.timeout,
config.trust,
config.description,
config.include_tools,
config.exclude_tools,
);
}
}
return {
kind: 'local',
name: markdown.name,
@@ -520,6 +579,7 @@ export function markdownToAgentDefinition(
tools: markdown.tools,
}
: undefined,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
inputConfig,
metadata,
};
+13
View File
@@ -570,6 +570,19 @@ export class AgentRegistry {
},
};
if (overrides.tools) {
merged.toolConfig = {
tools: overrides.tools,
};
}
if (overrides.mcpServers) {
merged.mcpServers = {
...definition.mcpServers,
...overrides.mcpServers,
};
}
return merged;
}
+6
View File
@@ -14,6 +14,7 @@ import { type z } from 'zod';
import type { ModelConfig } from '../services/modelConfigService.js';
import type { AnySchema } from 'ajv';
import type { A2AAuthConfig } from './auth-provider/types.js';
import type { MCPServerConfig } from '../config/config.js';
/**
* Describes the possible termination modes for an agent.
@@ -130,6 +131,11 @@ export interface LocalAgentDefinition<
// Optional configs
toolConfig?: ToolConfig;
/**
* Optional inline MCP servers for this agent.
*/
mcpServers?: Record<string, MCPServerConfig>;
/**
* An optional function to process the raw output from the agent's final tool
* call into a string format.
+2
View File
@@ -240,6 +240,8 @@ export interface AgentOverride {
modelConfig?: ModelConfig;
runConfig?: AgentRunConfig;
enabled?: boolean;
tools?: string[];
mcpServers?: Record<string, MCPServerConfig>;
}
export interface AgentSettings {
+19
View File
@@ -435,6 +435,25 @@ export abstract class DeclarativeTool<
readonly extensionId?: string,
) {}
clone(messageBus?: MessageBus): this {
// Note: we cannot use structuredClone() here because it does not preserve
// prototype chains or handle non-serializable properties (like functions).
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const cloned = Object.assign(
// eslint-disable-next-line no-restricted-syntax
Object.create(Object.getPrototypeOf(this)),
this,
) as this;
if (messageBus) {
Object.defineProperty(cloned, 'messageBus', {
value: messageBus,
writable: false,
configurable: true,
});
}
return cloned;
}
get isReadOnly(): boolean {
return READ_ONLY_KINDS.includes(this.kind);
}