mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(settings): Agents settings hub + dedicated agent editor (#3005)
This commit is contained in:
@@ -134,20 +134,20 @@ const SettingsHome = () => {
|
||||
onClick: () => navigateToSettings('appearance'),
|
||||
},
|
||||
{
|
||||
id: 'agent-access',
|
||||
title: t('settings.agentAccess.title'),
|
||||
description: t('settings.agentAccess.menuDesc'),
|
||||
id: 'agents-settings',
|
||||
title: t('settings.agentsSection.title'),
|
||||
description: t('settings.agentsSection.menuDesc'),
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 7h10a2 2 0 012 2v6a2 2 0 01-2 2H7a2 2 0 01-2-2V9a2 2 0 012-2zm2 4h.01M15 11h.01M9.5 15h5"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('agent-access'),
|
||||
onClick: () => navigateToSettings('agents-settings'),
|
||||
},
|
||||
{
|
||||
id: 'mascot',
|
||||
@@ -165,22 +165,6 @@ const SettingsHome = () => {
|
||||
),
|
||||
onClick: () => navigateToSettings('mascot'),
|
||||
},
|
||||
{
|
||||
id: 'persona',
|
||||
title: t('settings.persona.menuTitle'),
|
||||
description: t('settings.persona.menuDesc'),
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('persona'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Features tile (Screen Awareness / Messaging Channels / Notifications /
|
||||
|
||||
@@ -156,12 +156,13 @@ describe('SettingsHome', () => {
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications');
|
||||
});
|
||||
|
||||
it('navigates to persona settings when Persona is clicked', async () => {
|
||||
it('navigates to the Agents section when Agents is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
await user.click(screen.getByText('Persona').closest('button')!);
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('persona');
|
||||
// Persona, Agent OS access, etc. now live under the Agents section page.
|
||||
await user.click(screen.getByText('Agents').closest('button')!);
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('agents-settings');
|
||||
});
|
||||
|
||||
it('navigates to /notifications inbox when Alerts is clicked', async () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
export type SettingsRoute =
|
||||
| 'home'
|
||||
| 'agents'
|
||||
| 'agents-settings'
|
||||
| 'agent-access'
|
||||
| 'account'
|
||||
| 'features'
|
||||
| 'messaging'
|
||||
@@ -122,6 +124,10 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/mascot')) return 'mascot';
|
||||
if (path.includes('/settings/persona')) return 'persona';
|
||||
if (path.includes('/settings/appearance')) return 'appearance';
|
||||
// `agents-settings` (the Agents section page) must be checked before the
|
||||
// shorter `agents` (the manage-agents registry panel) so it isn't swallowed.
|
||||
if (path.includes('/settings/agents-settings')) return 'agents-settings';
|
||||
if (path.includes('/settings/agent-access')) return 'agent-access';
|
||||
if (path.includes('/settings/agents')) return 'agents';
|
||||
if (path.includes('/settings/mcp-server')) return 'mcp-server';
|
||||
if (path.includes('/settings/dev-workflow')) return 'dev-workflow';
|
||||
@@ -181,14 +187,27 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
onClick: () => navigate('/settings/developer-options'),
|
||||
};
|
||||
|
||||
const agentsCrumb: BreadcrumbItem = {
|
||||
label: 'Agents',
|
||||
onClick: () => navigate('/settings/agents-settings'),
|
||||
};
|
||||
|
||||
const getBreadcrumbs = (): BreadcrumbItem[] => {
|
||||
switch (currentRoute) {
|
||||
// Section pages
|
||||
case 'account':
|
||||
case 'features':
|
||||
case 'ai':
|
||||
case 'agents-settings':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Leaf panels under the Agents section
|
||||
case 'agents':
|
||||
case 'agent-access':
|
||||
case 'autonomy':
|
||||
case 'persona':
|
||||
return [settingsCrumb, agentsCrumb];
|
||||
|
||||
// Leaf panels under account
|
||||
case 'recovery-phrase':
|
||||
case 'wallet-balances':
|
||||
@@ -233,7 +252,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'notification-routing':
|
||||
case 'mcp-server':
|
||||
case 'dev-workflow':
|
||||
case 'autonomy':
|
||||
return [settingsCrumb, developerCrumb];
|
||||
|
||||
// Developer options section page
|
||||
@@ -251,10 +269,6 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'mascot':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Persona panel sits at the top level of Settings.
|
||||
case 'persona':
|
||||
return [settingsCrumb];
|
||||
|
||||
// Appearance (theme) panel sits at the top level of Settings.
|
||||
case 'appearance':
|
||||
return [settingsCrumb];
|
||||
|
||||
@@ -197,15 +197,19 @@ const AgentAccessPanel = () => {
|
||||
|
||||
<div className="p-4 space-y-6">
|
||||
{!isTauri() && (
|
||||
<p className="text-sm text-coral">{t('settings.agentAccess.desktopOnly')}</p>
|
||||
<p className="text-sm text-coral-600 dark:text-coral-300">
|
||||
{t('settings.agentAccess.desktopOnly')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-ink-soft">{t('settings.agentAccess.loading')}</p>
|
||||
<p className="text-sm text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.loading')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-ink">
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.agentAccess.accessMode')}
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
@@ -216,27 +220,33 @@ const AgentAccessPanel = () => {
|
||||
onClick={() => selectTier(p.id)}
|
||||
className={`text-left rounded-lg border p-3 transition ${
|
||||
level === p.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-line hover:border-primary-300'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
|
||||
: 'border-stone-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full border ${
|
||||
level === p.id ? 'bg-primary-500 border-primary-500' : 'border-line'
|
||||
level === p.id
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-stone-300 dark:border-neutral-700'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-ink">{p.title}</span>
|
||||
<span className="font-medium text-stone-900 dark:text-neutral-100">
|
||||
{p.title}
|
||||
</span>
|
||||
{p.id === 'supervised' && (
|
||||
<span className="text-xs text-ink-soft">
|
||||
<span className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.defaultTag')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-soft">{p.description}</p>
|
||||
<p className="mt-1 text-xs text-stone-600 dark:text-neutral-400">
|
||||
{p.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
{level === 'full' && (
|
||||
<p className="rounded border border-coral/40 bg-coral/5 p-2 text-xs text-coral">
|
||||
<p className="rounded border border-coral/40 bg-coral/5 dark:bg-coral/10 p-2 text-xs text-coral-600 dark:text-coral-300">
|
||||
{t('settings.agentAccess.fullWarning')}
|
||||
</p>
|
||||
)}
|
||||
@@ -253,10 +263,10 @@ const AgentAccessPanel = () => {
|
||||
onChange={e => toggleWorkspaceOnly(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm font-medium text-ink">
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.agentAccess.confine.label')}
|
||||
</span>
|
||||
<span className="block text-xs text-ink-soft">
|
||||
<span className="block text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.confine.desc')}
|
||||
</span>
|
||||
</span>
|
||||
@@ -272,10 +282,10 @@ const AgentAccessPanel = () => {
|
||||
onChange={e => toggleTaskPlanApproval(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm font-medium text-ink">
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.agentAccess.requireTaskPlanApproval.label')}
|
||||
</span>
|
||||
<span className="block text-xs text-ink-soft">
|
||||
<span className="block text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.requireTaskPlanApproval.desc')}
|
||||
</span>
|
||||
</span>
|
||||
@@ -284,21 +294,27 @@ const AgentAccessPanel = () => {
|
||||
|
||||
{/* Granted folders (trusted roots) — extra read/write reach. */}
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-ink">
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.agentAccess.grantedFolders')}
|
||||
</h2>
|
||||
<p className="text-xs text-ink-soft">{t('settings.agentAccess.grantedDesc')}</p>
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.grantedDesc')}
|
||||
</p>
|
||||
{trustedRoots.length === 0 ? (
|
||||
<p className="text-xs text-ink-soft">{t('settings.agentAccess.noneGranted')}</p>
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.noneGranted')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{trustedRoots.map(r => (
|
||||
<li
|
||||
key={r.path}
|
||||
className="flex items-center justify-between rounded border border-line px-2 py-1">
|
||||
<span className="font-mono text-xs text-ink truncate">{r.path}</span>
|
||||
className="flex items-center justify-between rounded border border-stone-200 dark:border-neutral-800 px-2 py-1">
|
||||
<span className="font-mono text-xs text-stone-900 dark:text-neutral-100 truncate">
|
||||
{r.path}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-ink-soft">
|
||||
<span className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{r.access === 'readwrite'
|
||||
? t('settings.agentAccess.readWrite')
|
||||
: t('settings.agentAccess.readOnly')}
|
||||
@@ -306,7 +322,7 @@ const AgentAccessPanel = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRoot(r.path)}
|
||||
className="text-xs text-coral hover:underline">
|
||||
className="text-xs text-coral-600 dark:text-coral-300 hover:underline">
|
||||
{t('settings.agentAccess.remove')}
|
||||
</button>
|
||||
</span>
|
||||
@@ -321,13 +337,13 @@ const AgentAccessPanel = () => {
|
||||
onChange={e => setNewRootPath(e.target.value)}
|
||||
placeholder={t('settings.agentAccess.pathPlaceholder')}
|
||||
aria-label={t('settings.agentAccess.pathPlaceholder')}
|
||||
className="flex-1 rounded border border-line px-2 py-1 text-xs font-mono"
|
||||
className="flex-1 rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 px-2 py-1 text-xs font-mono"
|
||||
/>
|
||||
<select
|
||||
value={newRootAccess}
|
||||
onChange={e => setNewRootAccess(e.target.value as TrustedAccess)}
|
||||
aria-label={t('settings.agentAccess.accessLevelLabel')}
|
||||
className="rounded border border-line px-2 py-1 text-xs">
|
||||
className="rounded border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 px-2 py-1 text-xs">
|
||||
<option value="read">{t('settings.agentAccess.readOnly')}</option>
|
||||
<option value="readwrite">{t('settings.agentAccess.readWrite')}</option>
|
||||
</select>
|
||||
@@ -344,23 +360,29 @@ const AgentAccessPanel = () => {
|
||||
prompted for, via the in-chat approval card. Read-only here with
|
||||
a Remove action to re-enable prompting for a tool. */}
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-ink">
|
||||
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('settings.agentAccess.alwaysAllow')}
|
||||
</h2>
|
||||
<p className="text-xs text-ink-soft">{t('settings.agentAccess.alwaysAllowDesc')}</p>
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.alwaysAllowDesc')}
|
||||
</p>
|
||||
{autoApprove.length === 0 ? (
|
||||
<p className="text-xs text-ink-soft">{t('settings.agentAccess.alwaysAllowNone')}</p>
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.alwaysAllowNone')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{autoApprove.map(tool => (
|
||||
<li
|
||||
key={tool}
|
||||
className="flex items-center justify-between rounded border border-line px-2 py-1">
|
||||
<span className="font-mono text-xs text-ink truncate">{tool}</span>
|
||||
className="flex items-center justify-between rounded border border-stone-200 dark:border-neutral-800 px-2 py-1">
|
||||
<span className="font-mono text-xs text-stone-900 dark:text-neutral-100 truncate">
|
||||
{tool}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAutoApprove(tool)}
|
||||
className="text-xs text-coral hover:underline">
|
||||
className="text-xs text-coral-600 dark:text-coral-300 hover:underline">
|
||||
{t('settings.agentAccess.remove')}
|
||||
</button>
|
||||
</li>
|
||||
@@ -372,13 +394,17 @@ const AgentAccessPanel = () => {
|
||||
{/* Auto-save status — changes persist on selection; no manual save. */}
|
||||
<div className="min-h-[1.25rem] text-sm" aria-live="polite">
|
||||
{error ? (
|
||||
<span className="text-coral">{error}</span>
|
||||
<span className="text-coral-600 dark:text-coral-300">{error}</span>
|
||||
) : isSaving ? (
|
||||
<span className="text-ink-soft">{t('settings.agentAccess.saving')}</span>
|
||||
<span className="text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.saving')}
|
||||
</span>
|
||||
) : savedNote ? (
|
||||
<span className="text-sage">✓ {savedNote}</span>
|
||||
<span className="text-sage-700 dark:text-sage-300">✓ {savedNote}</span>
|
||||
) : (
|
||||
<span className="text-ink-soft">{t('settings.agentAccess.changesApply')}</span>
|
||||
<span className="text-stone-600 dark:text-neutral-400">
|
||||
{t('settings.agentAccess.changesApply')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
|
||||
import AgentEditorPage from './AgentEditorPage';
|
||||
|
||||
vi.mock('../../../services/api/agentRegistryApi', () => ({
|
||||
agentRegistryApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
availableTools: vi.fn(),
|
||||
createCustom: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||
}));
|
||||
|
||||
const mockGet = vi.mocked(agentRegistryApi.get);
|
||||
const mockAvailableTools = vi.mocked(agentRegistryApi.availableTools);
|
||||
const mockCreate = vi.mocked(agentRegistryApi.createCustom);
|
||||
const mockUpdate = vi.mocked(agentRegistryApi.update);
|
||||
|
||||
function agent(overrides: Partial<AgentRegistryEntry> = {}): AgentRegistryEntry {
|
||||
return {
|
||||
id: 'finance',
|
||||
name: 'Finance',
|
||||
description: 'Crunches numbers.',
|
||||
source: 'custom',
|
||||
enabled: true,
|
||||
model: 'reasoning-v1',
|
||||
system_prompt: 'Be precise.',
|
||||
tool_allowlist: ['memory.search'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/settings/agents/new" element={<AgentEditorPage />} />
|
||||
<Route path="/settings/agents/edit/:id" element={<AgentEditorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentEditorPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAvailableTools.mockResolvedValue([
|
||||
{ name: 'web_search', description: 'Search the web for information.' },
|
||||
{ name: 'memory.search', description: 'Search the user memory store.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a custom agent from the form', async () => {
|
||||
mockCreate.mockResolvedValue(agent({ id: 'helper', name: 'Helper' }));
|
||||
renderAt('/settings/agents/new');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Helper' } });
|
||||
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Helps out.' } });
|
||||
// Model dropdown offers known tiers/hints.
|
||||
expect(screen.getByRole('option', { name: 'reasoning-v1' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'hint:coding' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create agent/ }));
|
||||
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
const arg = mockCreate.mock.calls[0][0];
|
||||
expect(arg.id).toBe('helper'); // auto-slugified from name
|
||||
expect(arg.name).toBe('Helper');
|
||||
expect(arg.model).toBe('hint:coding');
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/agents');
|
||||
});
|
||||
|
||||
it('picks tools from the searchable modal and shows chips', async () => {
|
||||
renderAt('/settings/agents/new');
|
||||
|
||||
fireEvent.click(screen.getByText('Add tools'));
|
||||
await waitFor(() => expect(mockAvailableTools).toHaveBeenCalled());
|
||||
|
||||
// Tool descriptions are shown in the modal.
|
||||
expect(await screen.findByText('Search the web for information.')).toBeInTheDocument();
|
||||
|
||||
// Search filters the list.
|
||||
fireEvent.change(screen.getByLabelText('Search tools…'), { target: { value: 'web' } });
|
||||
expect(screen.queryByText('Search the user memory store.')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('web_search'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Done' }));
|
||||
|
||||
// Chip for the selected tool appears on the page.
|
||||
await waitFor(() => expect(screen.getAllByText('web_search').length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('loads an existing agent for editing with a read-only name', async () => {
|
||||
mockGet.mockResolvedValue(agent());
|
||||
renderAt('/settings/agents/edit/finance');
|
||||
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('finance'));
|
||||
// Name is read-only in edit mode — no editable Name input is rendered.
|
||||
expect(screen.queryByLabelText('Name')).toBeNull();
|
||||
expect(screen.getByDisplayValue('Crunches numbers.')).toBeInTheDocument();
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('reasoning-v1');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Updated.' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('finance', expect.any(Object)));
|
||||
});
|
||||
|
||||
it('shows a read-only notice for built-in agents instead of the form', async () => {
|
||||
mockGet.mockResolvedValue(agent({ id: 'researcher', name: 'Researcher', source: 'default' }));
|
||||
renderAt('/settings/agents/edit/researcher');
|
||||
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('researcher'));
|
||||
expect(screen.getByText(/Built-in agents can.t be edited/)).toBeInTheDocument();
|
||||
// No editable form fields are rendered.
|
||||
expect(screen.queryByLabelText('Description')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* AgentEditorPage — Settings > Agents > (New | Edit).
|
||||
*
|
||||
* Full-page editor for a registry agent (replaces the old in-panel modal).
|
||||
* Routes: `/settings/agents/new` (create) and `/settings/agents/edit/:id`
|
||||
* (edit a default override or a custom agent).
|
||||
*
|
||||
* Field rules:
|
||||
* - Name is the page title; it is editable only when creating. On edit it is
|
||||
* shown read-only (the agent's identity stays stable).
|
||||
* - Description is a textarea.
|
||||
* - Model is a dropdown of known route hints / tiers, with a custom-id escape
|
||||
* hatch for BYOK provider model ids. Empty = inherit (no override).
|
||||
* - Allowed tools open a searchable modal with chip-style selection; each tool
|
||||
* shows its description. `["*"]` means "all tools".
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LuPlus, LuSearch, LuX } from 'react-icons/lu';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
agentRegistryApi,
|
||||
type AgentRegistryEntry,
|
||||
type AgentToolInfo,
|
||||
} from '../../../services/api/agentRegistryApi';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
|
||||
// Known model options — mirrors the Rust tier constants + route hints
|
||||
// (src/openhuman/config/schema/types.rs, inference/provider/router.rs).
|
||||
// Empty string means "inherit" (no override). Any other value not in this list
|
||||
// is treated as a raw BYOK provider model id (custom).
|
||||
const MODEL_HINTS = [
|
||||
'hint:reasoning',
|
||||
'hint:chat',
|
||||
'hint:agentic',
|
||||
'hint:coding',
|
||||
'hint:summarization',
|
||||
];
|
||||
const MODEL_TIERS = [
|
||||
'reasoning-v1',
|
||||
'reasoning-quick-v1',
|
||||
'chat-v1',
|
||||
'agentic-v1',
|
||||
'coding-v1',
|
||||
'summarization-v1',
|
||||
];
|
||||
const KNOWN_MODELS = new Set([...MODEL_HINTS, ...MODEL_TIERS]);
|
||||
const CUSTOM_MODEL = '__custom__';
|
||||
const ALL_TOOLS = '*';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-md border border-stone-200 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50';
|
||||
|
||||
const AgentEditorPage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { id: routeId } = useParams<{ id: string }>();
|
||||
const backToList = useCallback(() => navigate('/settings/agents'), [navigate]);
|
||||
const isCreate = !routeId;
|
||||
|
||||
const [loading, setLoading] = useState(!isCreate);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isCustom, setIsCustom] = useState(true);
|
||||
|
||||
// Form state.
|
||||
const [name, setName] = useState('');
|
||||
const [agentId, setAgentId] = useState('');
|
||||
const [idTouched, setIdTouched] = useState(!isCreate);
|
||||
const [description, setDescription] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [customModelMode, setCustomModelMode] = useState(false);
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [toolAllowlist, setToolAllowlist] = useState<string[]>([]);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreate || !routeId) return;
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const agent = await agentRegistryApi.get(routeId);
|
||||
if (cancelled) return;
|
||||
if (!agent) {
|
||||
setLoadError(t('settings.agents.editor.notFound'));
|
||||
return;
|
||||
}
|
||||
populate(agent);
|
||||
} catch (err) {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const populate = (agent: AgentRegistryEntry) => {
|
||||
setIsCustom(agent.source === 'custom');
|
||||
setName(agent.name);
|
||||
setAgentId(agent.id);
|
||||
setDescription(agent.description);
|
||||
const m = agent.model ?? '';
|
||||
setModel(m);
|
||||
setCustomModelMode(m !== '' && !KNOWN_MODELS.has(m));
|
||||
setSystemPrompt(agent.system_prompt ?? '');
|
||||
setToolAllowlist(agent.tool_allowlist ?? []);
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isCreate, routeId, t]);
|
||||
|
||||
const handleName = (value: string) => {
|
||||
setName(value);
|
||||
if (isCreate && !idTouched) setAgentId(slugify(value));
|
||||
};
|
||||
|
||||
const allToolsSelected = toolAllowlist.length === 1 && toolAllowlist[0] === ALL_TOOLS;
|
||||
|
||||
const canSubmit =
|
||||
!submitting &&
|
||||
description.trim().length > 0 &&
|
||||
(isCreate ? name.trim().length > 0 && agentId.trim().length > 0 : true);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const trimmedModel = model.trim();
|
||||
try {
|
||||
let saved: AgentRegistryEntry;
|
||||
if (isCreate) {
|
||||
saved = await agentRegistryApi.createCustom({
|
||||
id: agentId.trim() || slugify(name),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
model: trimmedModel || null,
|
||||
system_prompt: systemPrompt.trim() || null,
|
||||
tool_allowlist: toolAllowlist,
|
||||
});
|
||||
} else {
|
||||
saved = await agentRegistryApi.update(routeId, {
|
||||
description: description.trim(),
|
||||
// Always send a string so "inherit" (empty) clears any prior override.
|
||||
model: trimmedModel,
|
||||
system_prompt: systemPrompt.trim() || null,
|
||||
tool_allowlist: toolAllowlist,
|
||||
});
|
||||
}
|
||||
if (mountedRef.current && saved) backToList();
|
||||
} catch (err) {
|
||||
if (mountedRef.current) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (mountedRef.current) setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = isCreate
|
||||
? t('settings.agents.editor.createTitle')
|
||||
: name || t('settings.agents.editor.editTitle');
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Settings', onClick: () => navigate('/settings') },
|
||||
{ label: t('settings.agents.title'), onClick: () => navigate('/settings/agents') },
|
||||
];
|
||||
|
||||
const selectValue = customModelMode ? CUSTOM_MODEL : model;
|
||||
|
||||
const onModelSelect = (value: string) => {
|
||||
if (value === CUSTOM_MODEL) {
|
||||
setCustomModelMode(true);
|
||||
setModel('');
|
||||
} else {
|
||||
setCustomModelMode(false);
|
||||
setModel(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader title={title} showBackButton onBack={backToList} breadcrumbs={breadcrumbs} />
|
||||
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('settings.agents.loadError')}: {loadError}
|
||||
</div>
|
||||
) : !isCreate && !isCustom ? (
|
||||
// Built-in agents can't be edited; they may only be enabled/disabled
|
||||
// or reset from the agents list.
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300">
|
||||
{t('settings.agents.editor.builtInReadonly')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToList}
|
||||
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Name — editable only on create; read-only identity on edit. */}
|
||||
{isCreate ? (
|
||||
<Field label={t('settings.agents.editor.name')}>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => handleName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label={t('settings.agents.editor.name')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{name}
|
||||
</span>
|
||||
<span className="rounded-full bg-stone-100 px-2 py-0.5 text-[10px] font-medium text-stone-500 dark:bg-neutral-800 dark:text-neutral-400">
|
||||
{isCustom
|
||||
? t('settings.agents.sourceCustom')
|
||||
: t('settings.agents.sourceDefault')}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* ID — editable only on create. */}
|
||||
{isCreate ? (
|
||||
<Field
|
||||
label={t('settings.agents.editor.id')}
|
||||
hint={t('settings.agents.editor.idHint')}>
|
||||
<input
|
||||
value={agentId}
|
||||
onChange={e => {
|
||||
setIdTouched(true);
|
||||
setAgentId(e.target.value);
|
||||
}}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label={t('settings.agents.editor.id')}>
|
||||
<code className="block font-mono text-xs text-stone-500 dark:text-neutral-400">
|
||||
{agentId}
|
||||
</code>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={t('settings.agents.editor.description')}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-y`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Model — dropdown of known hints/tiers + custom escape hatch. */}
|
||||
<Field label={t('settings.agents.editor.model')}>
|
||||
<select
|
||||
value={selectValue}
|
||||
onChange={e => onModelSelect(e.target.value)}
|
||||
className={inputClass}>
|
||||
<option value="">{t('settings.agents.editor.modelInherit')}</option>
|
||||
<optgroup label={t('settings.agents.editor.modelHints')}>
|
||||
{MODEL_HINTS.map(h => (
|
||||
<option key={h} value={h}>
|
||||
{h}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label={t('settings.agents.editor.modelTiers')}>
|
||||
{MODEL_TIERS.map(m => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<option value={CUSTOM_MODEL}>{t('settings.agents.editor.modelCustom')}</option>
|
||||
</select>
|
||||
{customModelMode && (
|
||||
<input
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder={t('settings.agents.editor.modelCustomPlaceholder')}
|
||||
className={`${inputClass} mt-2 font-mono`}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label={t('settings.agents.editor.systemPrompt')}>
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={e => setSystemPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className={`${inputClass} resize-y`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Allowed tools — chips + modal picker. */}
|
||||
<Field
|
||||
label={t('settings.agents.editor.tools')}
|
||||
hint={t('settings.agents.editor.toolsHint')}>
|
||||
<div className="rounded-md border border-stone-200 p-2 dark:border-neutral-700">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{allToolsSelected ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-ocean-50 px-2.5 py-1 text-xs font-medium text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200">
|
||||
{t('settings.agents.editor.toolsAllSelected')}
|
||||
</span>
|
||||
) : toolAllowlist.length === 0 ? (
|
||||
<span className="px-1 py-1 text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.editor.toolsNoneSelected')}
|
||||
</span>
|
||||
) : (
|
||||
toolAllowlist.map(tool => (
|
||||
<span
|
||||
key={tool}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2.5 py-1 font-mono text-xs text-stone-700 dark:bg-neutral-800 dark:text-neutral-200">
|
||||
{tool}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.agents.editor.removeToolAria').replace(
|
||||
'{tool}',
|
||||
tool
|
||||
)}
|
||||
onClick={() => setToolAllowlist(prev => prev.filter(x => x !== tool))}
|
||||
className="rounded-full text-stone-400 hover:text-coral-600 dark:text-neutral-500 dark:hover:text-coral-300">
|
||||
<LuX className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToolsOpen(true)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-stone-300 px-2.5 py-1 text-xs font-medium text-stone-600 hover:border-ocean-400 hover:text-ocean-600 dark:border-neutral-700 dark:text-neutral-300 dark:hover:border-ocean-500 dark:hover:text-ocean-300">
|
||||
<LuPlus className="h-3 w-3" />
|
||||
{t('settings.agents.editor.selectTools')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{!isCreate && !isCustom && (
|
||||
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.editor.defaultsNote')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToList}
|
||||
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
|
||||
{submitting
|
||||
? t('settings.agents.editor.saving')
|
||||
: isCreate
|
||||
? t('settings.agents.editor.create')
|
||||
: t('settings.agents.editor.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toolsOpen && (
|
||||
<ToolsPickerModal
|
||||
allToolsSelected={allToolsSelected}
|
||||
selected={toolAllowlist}
|
||||
onToggleAll={() => setToolAllowlist(prev => (prev[0] === ALL_TOOLS ? [] : [ALL_TOOLS]))}
|
||||
onToggleTool={tool =>
|
||||
setToolAllowlist(prev => {
|
||||
const base = prev[0] === ALL_TOOLS ? [] : prev;
|
||||
return base.includes(tool) ? base.filter(x => x !== tool) : [...base, tool];
|
||||
})
|
||||
}
|
||||
onClose={() => setToolsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function ToolsPickerModal({
|
||||
allToolsSelected,
|
||||
selected,
|
||||
onToggleAll,
|
||||
onToggleTool,
|
||||
onClose,
|
||||
}: {
|
||||
allToolsSelected: boolean;
|
||||
selected: string[];
|
||||
onToggleAll: () => void;
|
||||
onToggleTool: (tool: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const [tools, setTools] = useState<AgentToolInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await agentRegistryApi.availableTools();
|
||||
setTools(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return tools;
|
||||
return tools.filter(
|
||||
tool => tool.name.toLowerCase().includes(q) || tool.description.toLowerCase().includes(q)
|
||||
);
|
||||
}, [tools, query]);
|
||||
|
||||
const selectedCount = allToolsSelected ? tools.length : selected.length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4 py-6">
|
||||
<section className="flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-lg border border-stone-200 bg-white shadow-xl dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between border-b border-stone-200 px-4 py-3 dark:border-neutral-800">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-50">
|
||||
{t('settings.agents.editor.toolsModalTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.editor.toolsSelectedCount').replace(
|
||||
'{count}',
|
||||
String(selectedCount)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300">
|
||||
<LuX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-stone-200 px-4 py-3 dark:border-neutral-800">
|
||||
<div className="relative">
|
||||
<LuSearch className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-stone-400 dark:text-neutral-500" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('settings.agents.editor.toolsSearchPlaceholder')}
|
||||
aria-label={t('settings.agents.editor.toolsSearchPlaceholder')}
|
||||
className={`${inputClass} pl-8`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAll}
|
||||
className={`mt-2 flex w-full items-start justify-between gap-2 rounded-md border px-3 py-2 text-left transition-colors ${
|
||||
allToolsSelected
|
||||
? 'border-ocean-400 bg-ocean-50 dark:border-ocean-500/40 dark:bg-ocean-500/10'
|
||||
: 'border-stone-200 hover:bg-stone-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
|
||||
}`}>
|
||||
<span>
|
||||
<span className="block text-xs font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{t('settings.agents.editor.toolsAllowAll')}
|
||||
</span>
|
||||
<span className="block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.editor.toolsAllowAllHint')}
|
||||
</span>
|
||||
</span>
|
||||
<Checkbox checked={allToolsSelected} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[8rem] flex-1 overflow-y-auto px-2 py-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-stone-400 dark:text-neutral-500">
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('settings.agents.editor.toolsLoading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-coral-600 dark:text-coral-300">
|
||||
{t('settings.agents.editor.toolsLoadError')}: {error}
|
||||
</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.editor.toolsEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul>
|
||||
{filtered.map(tool => {
|
||||
const checked = allToolsSelected || selected.includes(tool.name);
|
||||
return (
|
||||
<li key={tool.name}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={allToolsSelected}
|
||||
onClick={() => onToggleTool(tool.name)}
|
||||
className="flex w-full items-start gap-3 rounded-md px-2 py-2 text-left hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800">
|
||||
<Checkbox checked={checked} className="mt-0.5" />
|
||||
<span className="min-w-0">
|
||||
<span className="block font-mono text-xs font-medium text-stone-800 dark:text-neutral-100">
|
||||
{tool.name}
|
||||
</span>
|
||||
<span className="block break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{tool.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-stone-200 px-4 py-3 dark:border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-ocean-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-ocean-700">
|
||||
{t('settings.agents.editor.toolsDone')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({ checked, className = '' }: { checked: boolean; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-none items-center justify-center rounded border transition-colors ${
|
||||
checked
|
||||
? 'border-ocean-600 bg-ocean-600 text-white'
|
||||
: 'border-stone-300 bg-white dark:border-neutral-600 dark:bg-neutral-950'
|
||||
} ${className}`}>
|
||||
{checked && (
|
||||
<svg className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.3 3.3 6.8-6.8a1 1 0 011.4 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint && (
|
||||
<span className="mt-1 block text-[11px] text-stone-400 dark:text-neutral-500">{hint}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentEditorPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
|
||||
@@ -8,6 +9,7 @@ vi.mock('../../../services/api/agentRegistryApi', () => ({
|
||||
agentRegistryApi: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
availableTools: vi.fn(),
|
||||
createCustom: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
@@ -15,8 +17,14 @@ vi.mock('../../../services/api/agentRegistryApi', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateBack: vi.fn() }),
|
||||
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('../SettingsHeader', () => ({
|
||||
@@ -26,6 +34,13 @@ vi.mock('../SettingsHeader', () => ({
|
||||
const mockList = vi.mocked(agentRegistryApi.list);
|
||||
const mockSetEnabled = vi.mocked(agentRegistryApi.setEnabled);
|
||||
|
||||
const renderPanel = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AgentsPanel />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
function agent(overrides: Partial<AgentRegistryEntry> = {}): AgentRegistryEntry {
|
||||
return {
|
||||
id: 'researcher',
|
||||
@@ -54,7 +69,7 @@ describe('AgentsPanel', () => {
|
||||
});
|
||||
|
||||
it('lists agents with their source badges', async () => {
|
||||
render(<AgentsPanel />);
|
||||
renderPanel();
|
||||
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
|
||||
expect(screen.getByText('Orchestrator')).toBeInTheDocument();
|
||||
expect(screen.getByText('Finance')).toBeInTheDocument();
|
||||
@@ -64,7 +79,7 @@ describe('AgentsPanel', () => {
|
||||
|
||||
it('toggles a non-orchestrator agent via setEnabled', async () => {
|
||||
mockSetEnabled.mockResolvedValue(agent({ id: 'researcher', enabled: false }));
|
||||
render(<AgentsPanel />);
|
||||
renderPanel();
|
||||
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
|
||||
|
||||
const switches = screen.getAllByRole('switch');
|
||||
@@ -75,17 +90,27 @@ describe('AgentsPanel', () => {
|
||||
await waitFor(() => expect(mockSetEnabled).toHaveBeenCalledWith('researcher', false));
|
||||
});
|
||||
|
||||
it('opens the create editor', async () => {
|
||||
render(<AgentsPanel />);
|
||||
it('navigates to the create editor page', async () => {
|
||||
renderPanel();
|
||||
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /New agent/ }));
|
||||
expect(screen.getByRole('button', { name: 'Create agent' })).toBeInTheDocument();
|
||||
expect(screen.getByText('ID')).toBeInTheDocument();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/agents/new');
|
||||
});
|
||||
|
||||
it('only offers Edit for custom agents and navigates to the edit page', async () => {
|
||||
renderPanel();
|
||||
await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument());
|
||||
// Two built-ins (orchestrator, researcher) + one custom (finance) — only the
|
||||
// custom agent exposes an Edit button.
|
||||
const editButtons = screen.getAllByRole('button', { name: /Edit/ });
|
||||
expect(editButtons).toHaveLength(1);
|
||||
fireEvent.click(editButtons[0]);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/agents/edit/finance');
|
||||
});
|
||||
|
||||
it('shows an error when loading fails', async () => {
|
||||
mockList.mockRejectedValueOnce(new Error('boom'));
|
||||
render(<AgentsPanel />);
|
||||
renderPanel();
|
||||
await waitFor(() => expect(screen.getByText(/Couldn't load agents/)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,46 +7,27 @@
|
||||
* built-in saves an override), and delete a custom agent / reset a built-in
|
||||
* override.
|
||||
*/
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LuPencil, LuPlus, LuRotateCcw, LuTrash2 } from 'react-icons/lu';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import {
|
||||
agentRegistryApi,
|
||||
type AgentRegistryEntry,
|
||||
type UpdateAgentInput,
|
||||
} from '../../../services/api/agentRegistryApi';
|
||||
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const ORCHESTRATOR_ID = 'orchestrator';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function splitLines(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const AgentsPanel = () => {
|
||||
const { t } = useT();
|
||||
const { navigateBack } = useSettingsNavigation();
|
||||
const navigate = useNavigate();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
const [agents, setAgents] = useState<AgentRegistryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AgentRegistryEntry | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -108,76 +89,63 @@ const AgentsPanel = () => {
|
||||
[load, t]
|
||||
);
|
||||
|
||||
const handleSaved = useCallback((saved: AgentRegistryEntry) => {
|
||||
setAgents(prev => {
|
||||
const exists = prev.some(a => a.id === saved.id);
|
||||
return exists ? prev.map(a => (a.id === saved.id ? saved : a)) : [...prev, saved];
|
||||
});
|
||||
setEditing(null);
|
||||
setCreating(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||
<SettingsHeader title={t('settings.agents.title')} onBack={navigateBack} />
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('settings.agents.title')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.agents.subtitle')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="inline-flex flex-none items-center gap-1.5 rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700">
|
||||
<LuPlus className="h-3.5 w-3.5" />
|
||||
{t('settings.agents.newAgent')}
|
||||
</button>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-stone-500 dark:text-neutral-400">
|
||||
{t('settings.agents.subtitle')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/agents/new')}
|
||||
className="inline-flex flex-none items-center gap-1.5 rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700">
|
||||
<LuPlus className="h-3.5 w-3.5" />
|
||||
{t('settings.agents.newAgent')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('settings.agents.loadError')}: {error}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-stone-200 overflow-hidden rounded-xl border border-stone-200 dark:divide-neutral-800 dark:border-neutral-800">
|
||||
{agents.map(agent => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
busy={busyId === agent.id}
|
||||
onToggle={() => handleToggle(agent)}
|
||||
onEdit={() => navigate(`/settings/agents/edit/${agent.id}`)}
|
||||
onRemove={() => handleRemove(agent)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('settings.agents.loadError')}: {error}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{agents.map(agent => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
busy={busyId === agent.id}
|
||||
onToggle={() => handleToggle(agent)}
|
||||
onEdit={() => setEditing(agent)}
|
||||
onRemove={() => handleRemove(agent)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{(editing || creating) && (
|
||||
<AgentEditor
|
||||
agent={editing}
|
||||
onClose={() => {
|
||||
setEditing(null);
|
||||
setCreating(false);
|
||||
}}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -204,70 +172,68 @@ function AgentRow({
|
||||
: t('settings.agents.toolsCount').replace('{count}', String(tools.length));
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`rounded-xl border border-stone-200 bg-white px-4 py-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900 ${
|
||||
agent.enabled ? '' : 'opacity-70'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
isCustom
|
||||
? 'bg-ocean-50 text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200'
|
||||
: 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}`}>
|
||||
{isCustom ? t('settings.agents.sourceCustom') : t('settings.agents.sourceDefault')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 break-words text-xs leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{agent.description}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
<code className="font-mono">{agent.id}</code>
|
||||
{agent.model && (
|
||||
<span>
|
||||
{t('settings.agents.modelLabel')}: {agent.model}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t('settings.agents.toolsLabel')}: {toolsLabel}
|
||||
</span>
|
||||
</div>
|
||||
<li className={`bg-white px-4 py-3 dark:bg-neutral-900 ${agent.enabled ? '' : 'opacity-70'}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
isCustom
|
||||
? 'bg-ocean-50 text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200'
|
||||
: 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}`}>
|
||||
{isCustom ? t('settings.agents.sourceCustom') : t('settings.agents.sourceDefault')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={agent.enabled}
|
||||
aria-label={agent.enabled ? t('settings.agents.disable') : t('settings.agents.enable')}
|
||||
disabled={busy || isOrchestrator}
|
||||
title={isOrchestrator ? t('settings.agents.orchestratorLocked') : undefined}
|
||||
onClick={onToggle}
|
||||
className={`relative h-5 w-9 flex-none rounded-full transition-colors disabled:opacity-40 ${
|
||||
agent.enabled ? 'bg-ocean-600' : 'bg-stone-300 dark:bg-neutral-700'
|
||||
}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
agent.enabled ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={agent.enabled}
|
||||
aria-label={agent.enabled ? t('settings.agents.disable') : t('settings.agents.enable')}
|
||||
disabled={busy || isOrchestrator}
|
||||
title={isOrchestrator ? t('settings.agents.orchestratorLocked') : undefined}
|
||||
onClick={onToggle}
|
||||
className={`relative h-5 w-9 flex-none rounded-full transition-colors disabled:opacity-40 ${
|
||||
agent.enabled ? 'bg-ocean-600' : 'bg-stone-300 dark:bg-neutral-700'
|
||||
}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform dark:bg-neutral-900 ${
|
||||
agent.enabled ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 break-words text-xs leading-snug text-stone-500 dark:text-neutral-400">
|
||||
{agent.description}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
<code className="font-mono">{agent.id}</code>
|
||||
{agent.model && (
|
||||
<span>
|
||||
{t('settings.agents.modelLabel')}: {agent.model}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t('settings.agents.toolsLabel')}: {toolsLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
<LuPencil className="h-3 w-3" />
|
||||
{t('settings.agents.edit')}
|
||||
</button>
|
||||
{/* Built-in agents can't be edited — only custom agents expose Edit.
|
||||
Built-ins keep the toggle (enable/disable) and Reset (clear override). */}
|
||||
{isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
<LuPencil className="h-3 w-3" />
|
||||
{t('settings.agents.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
@@ -281,192 +247,4 @@ function AgentRow({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEditor({
|
||||
agent,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
agent: AgentRegistryEntry | null;
|
||||
onClose: () => void;
|
||||
onSaved: (saved: AgentRegistryEntry) => void;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const isCreate = agent === null;
|
||||
const isCustom = agent?.source === 'custom';
|
||||
|
||||
const [id, setId] = useState(agent?.id ?? '');
|
||||
const [idTouched, setIdTouched] = useState(!isCreate);
|
||||
const [name, setName] = useState(agent?.name ?? '');
|
||||
const [description, setDescription] = useState(agent?.description ?? '');
|
||||
const [model, setModel] = useState(agent?.model ?? '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(agent?.system_prompt ?? '');
|
||||
const [tools, setTools] = useState((agent?.tool_allowlist ?? []).join('\n'));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Auto-derive id from name while creating, until the user edits it.
|
||||
const handleName = (value: string) => {
|
||||
setName(value);
|
||||
if (isCreate && !idTouched) setId(slugify(value));
|
||||
};
|
||||
|
||||
const canSubmit = name.trim().length > 0 && description.trim().length > 0 && !submitting;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const toolAllowlist = splitLines(tools);
|
||||
let saved: AgentRegistryEntry;
|
||||
if (isCreate) {
|
||||
saved = await agentRegistryApi.createCustom({
|
||||
id: id.trim() || slugify(name),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
model: model.trim() || null,
|
||||
system_prompt: systemPrompt.trim() || null,
|
||||
tool_allowlist: toolAllowlist,
|
||||
});
|
||||
} else {
|
||||
const patch: UpdateAgentInput = {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
model: model.trim() || null,
|
||||
system_prompt: systemPrompt.trim() || null,
|
||||
tool_allowlist: toolAllowlist,
|
||||
};
|
||||
saved = await agentRegistryApi.update(agent.id, patch);
|
||||
}
|
||||
onSaved(saved);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4 py-6">
|
||||
<section className="max-h-full w-full max-w-lg overflow-y-auto rounded-lg border border-stone-200 bg-white p-4 shadow-xl dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<h3 className="mb-3 text-base font-semibold text-stone-900 dark:text-neutral-50">
|
||||
{isCreate
|
||||
? t('settings.agents.editor.createTitle')
|
||||
: t('settings.agents.editor.editTitle')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<Field label={t('settings.agents.editor.name')}>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => handleName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isCreate && (
|
||||
<Field label={t('settings.agents.editor.id')} hint={t('settings.agents.editor.idHint')}>
|
||||
<input
|
||||
value={id}
|
||||
onChange={e => {
|
||||
setIdTouched(true);
|
||||
setId(e.target.value);
|
||||
}}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={t('settings.agents.editor.description')}>
|
||||
<input
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('settings.agents.editor.model')}>
|
||||
<input
|
||||
value={model ?? ''}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder={t('settings.agents.editor.modelPlaceholder')}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('settings.agents.editor.systemPrompt')}>
|
||||
<textarea
|
||||
value={systemPrompt ?? ''}
|
||||
onChange={e => setSystemPrompt(e.target.value)}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-y`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t('settings.agents.editor.tools')}
|
||||
hint={t('settings.agents.editor.toolsHint')}>
|
||||
<textarea
|
||||
value={tools}
|
||||
onChange={e => setTools(e.target.value)}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-y font-mono`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isCreate && !isCustom && (
|
||||
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.agents.editor.defaultsNote')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
|
||||
{submitting
|
||||
? t('settings.agents.editor.saving')
|
||||
: isCreate
|
||||
? t('settings.agents.editor.create')
|
||||
: t('settings.agents.editor.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50';
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint && (
|
||||
<span className="mt-1 block text-[11px] text-stone-400 dark:text-neutral-500">{hint}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentsPanel;
|
||||
|
||||
@@ -456,6 +456,31 @@ const ar2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default ar2;
|
||||
|
||||
@@ -469,6 +469,31 @@ const bn2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default bn2;
|
||||
|
||||
@@ -480,6 +480,31 @@ const de2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default de2;
|
||||
|
||||
@@ -465,6 +465,31 @@ const en2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default en2;
|
||||
|
||||
@@ -479,6 +479,31 @@ const es2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default es2;
|
||||
|
||||
@@ -481,6 +481,31 @@ const fr2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default fr2;
|
||||
|
||||
@@ -467,6 +467,31 @@ const hi2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default hi2;
|
||||
|
||||
@@ -467,6 +467,31 @@ const id2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default id2;
|
||||
|
||||
@@ -474,6 +474,31 @@ const it2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default it2;
|
||||
|
||||
@@ -455,6 +455,31 @@ const ko2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default ko2;
|
||||
|
||||
@@ -458,6 +458,31 @@ const pl2: TranslationMap = {
|
||||
'devOptions.menuComposioTriggers': 'Wyzwalacze integracji',
|
||||
'devOptions.menuComposioTriggersDesc':
|
||||
'Konfiguruj ustawienia klasyfikacji AI dla wyzwalaczy integracji Composio',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default pl2;
|
||||
|
||||
@@ -479,6 +479,31 @@ const pt2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default pt2;
|
||||
|
||||
@@ -474,6 +474,31 @@ const ru2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default ru2;
|
||||
|
||||
@@ -442,6 +442,31 @@ const zhCN2: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default zhCN2;
|
||||
|
||||
@@ -4275,6 +4275,31 @@ const en: TranslationMap = {
|
||||
'settings.agents.editor.save': 'Save',
|
||||
'settings.agents.editor.create': 'Create agent',
|
||||
'settings.agents.editor.saving': 'Saving…',
|
||||
'settings.agentsSection.title': 'Agents',
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
'settings.agents.editor.modelTiers': 'Model tiers',
|
||||
'settings.agents.editor.modelCustom': 'Custom model id…',
|
||||
'settings.agents.editor.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4',
|
||||
'settings.agents.editor.selectTools': 'Add tools',
|
||||
'settings.agents.editor.toolsAllSelected': 'All tools',
|
||||
'settings.agents.editor.toolsNoneSelected': 'No tools selected',
|
||||
'settings.agents.editor.removeToolAria': 'Remove {tool}',
|
||||
'settings.agents.editor.toolsModalTitle': 'Allowed tools',
|
||||
'settings.agents.editor.toolsSelectedCount': '{count} selected',
|
||||
'settings.agents.editor.toolsSearchPlaceholder': 'Search tools…',
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -6,6 +6,7 @@ import LogoutAndClearActions from '../components/settings/LogoutAndClearActions'
|
||||
import AboutPanel from '../components/settings/panels/AboutPanel';
|
||||
import AgentAccessPanel from '../components/settings/panels/AgentAccessPanel';
|
||||
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
|
||||
import AgentEditorPage from '../components/settings/panels/AgentEditorPage';
|
||||
import AgentsPanel from '../components/settings/panels/AgentsPanel';
|
||||
import AIPanel from '../components/settings/panels/AIPanel';
|
||||
import AppearancePanel from '../components/settings/panels/AppearancePanel';
|
||||
@@ -175,6 +176,28 @@ const VoiceIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AgentAccessIcon = (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PersonaIcon = (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WalletIcon = (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -321,13 +344,6 @@ const Settings = () => {
|
||||
route: 'agent-chat',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
{
|
||||
id: 'autonomy',
|
||||
title: t('settings.developerMenu.autonomy.title'),
|
||||
description: t('settings.developerMenu.autonomy.desc'),
|
||||
route: 'autonomy',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
{
|
||||
id: 'local-model-debug',
|
||||
title: t('settings.developerMenu.localModelDebug.title'),
|
||||
@@ -358,6 +374,37 @@ const Settings = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const agentsSettingsItems = [
|
||||
{
|
||||
id: 'agents',
|
||||
title: t('settings.agents.title'),
|
||||
description: t('settings.agents.subtitle'),
|
||||
route: 'agents',
|
||||
icon: ToolsIcon,
|
||||
},
|
||||
{
|
||||
id: 'persona',
|
||||
title: t('settings.persona.menuTitle'),
|
||||
description: t('settings.persona.menuDesc'),
|
||||
route: 'persona',
|
||||
icon: PersonaIcon,
|
||||
},
|
||||
{
|
||||
id: 'autonomy',
|
||||
title: t('settings.developerMenu.autonomy.title'),
|
||||
description: t('settings.developerMenu.autonomy.desc'),
|
||||
route: 'autonomy',
|
||||
icon: LlmIcon,
|
||||
},
|
||||
{
|
||||
id: 'agent-access',
|
||||
title: t('settings.agentAccess.title'),
|
||||
description: t('settings.agentAccess.menuDesc'),
|
||||
route: 'agent-access',
|
||||
icon: AgentAccessIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const composioSettingsItems = [
|
||||
{
|
||||
id: 'task-sources',
|
||||
@@ -427,6 +474,16 @@ const Settings = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="agents-settings"
|
||||
element={wrapSettingsPage(
|
||||
<SettingsSectionPage
|
||||
title={t('settings.agentsSection.title')}
|
||||
description={t('settings.agentsSection.description')}
|
||||
items={agentsSettingsItems}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Account & Billing leaf panels */}
|
||||
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
|
||||
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
|
||||
@@ -456,6 +513,8 @@ const Settings = () => {
|
||||
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
|
||||
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
|
||||
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
|
||||
<Route path="agents/new" element={wrapSettingsPage(<AgentEditorPage />)} />
|
||||
<Route path="agents/edit/:id" element={wrapSettingsPage(<AgentEditorPage />)} />
|
||||
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
|
||||
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
|
||||
{/* Developer Options */}
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface CreateCustomAgentInput {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** Mirror of the Rust `AgentToolInfo` — one available tool for the picker. */
|
||||
export interface AgentToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Patch for `update` — any omitted field is left unchanged. */
|
||||
export interface UpdateAgentInput {
|
||||
name?: string;
|
||||
@@ -80,6 +86,18 @@ export const agentRegistryApi = {
|
||||
return res?.agents ?? [];
|
||||
},
|
||||
|
||||
/** List every agent tool visible to the orchestrator, with descriptions.
|
||||
* Used by the agent editor's tool picker; each `name` is a valid
|
||||
* `tool_allowlist` entry. */
|
||||
availableTools: async (): Promise<AgentToolInfo[]> => {
|
||||
log('availableTools');
|
||||
const res = await callCoreRpc<{ tools?: AgentToolInfo[] }>({
|
||||
method: 'openhuman.agent_registry_available_tools',
|
||||
params: {},
|
||||
});
|
||||
return res?.tools ?? [];
|
||||
},
|
||||
|
||||
/** Fetch a single entry by id. */
|
||||
get: async (id: string): Promise<AgentRegistryEntry | null> => {
|
||||
log('get id=%s', id);
|
||||
|
||||
@@ -104,16 +104,6 @@ named = [
|
||||
"save_preference",
|
||||
"memory_forget",
|
||||
"memory_tree",
|
||||
# WhatsApp local-data tools (issue #1341). The scanner ingests chats
|
||||
# and messages into `whatsapp_data.db` on the user's machine; these
|
||||
# three read-only tools let the orchestrator quote, summarise and
|
||||
# search that local data without exposing the scanner's
|
||||
# `whatsapp_data_ingest` write-path. Pair with the `memory_tree`
|
||||
# tool above for cross-source / action-item flows once the scanner
|
||||
# also forwards messages into the memory tree.
|
||||
"whatsapp_data_list_chats",
|
||||
"whatsapp_data_list_messages",
|
||||
"whatsapp_data_search_messages",
|
||||
"read_workspace_state",
|
||||
"ask_user_clarification",
|
||||
"spawn_worker_thread",
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use crate::openhuman::agent::Agent;
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
|
||||
use super::defaults::default_agents;
|
||||
use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource};
|
||||
use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource, AgentToolInfo};
|
||||
|
||||
const ORCHESTRATOR_AGENT_ID: &str = "orchestrator";
|
||||
|
||||
/// Wildcard agent whose tool surface is the complete built-in tool catalog.
|
||||
/// Used as the source for [`available_tools`] — the orchestrator's curated
|
||||
/// `named` list is only a subset, so it can't back a general tool picker.
|
||||
const TOOLS_CATALOG_AGENT_ID: &str = "tools_agent";
|
||||
|
||||
pub async fn list_agents(include_disabled: bool) -> Result<Vec<AgentRegistryEntry>, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
Ok(merge_entries(
|
||||
@@ -129,6 +136,37 @@ pub async fn remove_agent(id: &str) -> Result<bool, String> {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// List every assignable agent tool, with descriptions, for the editor's
|
||||
/// tool picker.
|
||||
///
|
||||
/// Built from the wildcard [`TOOLS_CATALOG_AGENT_ID`] agent's `tool_specs()`:
|
||||
/// its `ToolScope::Wildcard` definition resolves to the full built-in tool
|
||||
/// catalog, so the names returned here are exactly the identifiers a
|
||||
/// `tool_allowlist` is matched against. (The orchestrator uses a curated
|
||||
/// `named` subset, so it would yield an incomplete catalog.) Connected-
|
||||
/// integration / delegation tools are intentionally not fetched — the picker
|
||||
/// surfaces the stable built-in surface only. Sorted + deduped by name for a
|
||||
/// stable picker UI.
|
||||
pub async fn available_tools() -> Result<Vec<AgentToolInfo>, String> {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
AgentDefinitionRegistry::init_global(&config.workspace_dir)
|
||||
.map_err(|e| format!("failed to initialise AgentDefinitionRegistry: {e}"))?;
|
||||
let agent = Agent::from_config_for_agent(&config, TOOLS_CATALOG_AGENT_ID)
|
||||
.map_err(|e| format!("failed to build tools-catalog agent: {e}"))?;
|
||||
|
||||
let mut tools: Vec<AgentToolInfo> = agent
|
||||
.tool_specs()
|
||||
.iter()
|
||||
.map(|spec| AgentToolInfo {
|
||||
name: spec.name.clone(),
|
||||
description: spec.description.clone(),
|
||||
})
|
||||
.collect();
|
||||
tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
tools.dedup_by(|a, b| a.name == b.name);
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
pub fn merge_entries(
|
||||
configured: &[AgentRegistryEntry],
|
||||
include_disabled: bool,
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde_json::Value;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::ops;
|
||||
use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource};
|
||||
use super::types::{AgentRegistryEntry, AgentRegistryPatch, AgentRegistrySource, AgentToolInfo};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListRequest {
|
||||
@@ -28,6 +28,25 @@ pub async fn list_rpc(req: ListRequest) -> Result<RpcOutcome<ListResponse>, Stri
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct AvailableToolsRequest {}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AvailableToolsResponse {
|
||||
pub tools: Vec<AgentToolInfo>,
|
||||
}
|
||||
|
||||
pub async fn available_tools_rpc(
|
||||
_req: AvailableToolsRequest,
|
||||
) -> Result<RpcOutcome<AvailableToolsResponse>, String> {
|
||||
Ok(RpcOutcome::new(
|
||||
AvailableToolsResponse {
|
||||
tools: ops::available_tools().await?,
|
||||
},
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetRequest {
|
||||
pub id: String,
|
||||
|
||||
@@ -14,6 +14,7 @@ const NAMESPACE: &str = "agent_registry";
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("list"),
|
||||
schemas("available_tools"),
|
||||
schemas("get"),
|
||||
schemas("create_custom"),
|
||||
schemas("upsert_custom"),
|
||||
@@ -29,6 +30,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("list"),
|
||||
handler: handle_list,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("available_tools"),
|
||||
handler: handle_available_tools,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get"),
|
||||
handler: handle_get,
|
||||
@@ -75,6 +80,18 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"available_tools" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "available_tools",
|
||||
description: "List every assignable agent tool (the full built-in tool catalog), with descriptions, for the agent editor's tool picker.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "tools",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("AgentToolInfo"))),
|
||||
comment: "Available tools sorted by name; each name is a valid tool_allowlist entry.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"get" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "get",
|
||||
@@ -185,6 +202,13 @@ fn handle_list(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_available_tools(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let req = parse_value::<rpc::AvailableToolsRequest>(Value::Object(params))?;
|
||||
to_json(rpc::available_tools_rpc(req).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let req = parse_value::<rpc::GetRequest>(Value::Object(params))?;
|
||||
@@ -297,4 +321,21 @@ mod tests {
|
||||
fn schemas_panics_on_unknown_function() {
|
||||
schemas("missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_tools_schema_is_registered_with_tools_output() {
|
||||
let schema = schemas("available_tools");
|
||||
assert_eq!(schema.namespace, NAMESPACE);
|
||||
assert_eq!(schema.function, "available_tools");
|
||||
assert!(schema.inputs.is_empty());
|
||||
let tools = schema
|
||||
.outputs
|
||||
.iter()
|
||||
.find(|field| field.name == "tools")
|
||||
.expect("available_tools should output a `tools` field");
|
||||
assert!(tools.required);
|
||||
assert!(all_controller_schemas()
|
||||
.iter()
|
||||
.any(|s| s.function == "available_tools"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,16 @@ impl AgentRegistryEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// One available agent tool, surfaced to the agent editor so users can pick
|
||||
/// `tool_allowlist` entries from a searchable catalog with descriptions.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AgentToolInfo {
|
||||
/// Tool name — the exact identifier used in `tool_allowlist`.
|
||||
pub name: String,
|
||||
/// Human-readable description of what the tool does.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AgentRegistryConfig {
|
||||
/// User-authored agents plus persisted overrides for shipped default agents.
|
||||
|
||||
@@ -1500,6 +1500,45 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() {
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// The agent editor's tool picker is fed by available_tools — every entry is
|
||||
// a {name, description} pair whose name is a valid tool_allowlist value.
|
||||
let available_tools = post_json_rpc(
|
||||
&rpc_base,
|
||||
2862_24,
|
||||
"openhuman.agent_registry_available_tools",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let tools = assert_no_jsonrpc_error(&available_tools, "agent_registry_available_tools")
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.expect("available_tools should return a tools array");
|
||||
assert!(
|
||||
!tools.is_empty(),
|
||||
"the orchestrator should expose at least one built-in tool"
|
||||
);
|
||||
let first = tools.first().expect("non-empty tools");
|
||||
assert!(
|
||||
first.get("name").and_then(Value::as_str).is_some(),
|
||||
"each tool should have a string name: {first}"
|
||||
);
|
||||
assert!(
|
||||
first.get("description").and_then(Value::as_str).is_some(),
|
||||
"each tool should have a string description: {first}"
|
||||
);
|
||||
// The catalog is the full built-in surface (wildcard agent), not the
|
||||
// orchestrator's curated `named` subset — so a core read tool like
|
||||
// `file_read`, which the orchestrator does not list directly, must appear.
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
|
||||
.collect();
|
||||
assert!(
|
||||
names.contains(&"file_read"),
|
||||
"available_tools should expose the full catalog (file_read missing): {names:?}"
|
||||
);
|
||||
|
||||
rpc_join.abort();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user