feat(settings): Agents settings hub + dedicated agent editor (#3005)

This commit is contained in:
Steven Enamakel
2026-05-29 22:37:55 -07:00
committed by GitHub
parent 029f007e6c
commit c3ee5d420f
31 changed files with 1605 additions and 427 deletions
+5 -21
View File
@@ -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());
});
});
+116 -338
View File
@@ -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;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default ar2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default bn2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default de2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default en2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default es2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default fr2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default hi2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default id2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default it2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default ko2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default pl2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default pt2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default ru2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default zhCN2;
+25
View File
@@ -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': 'Couldnt load tools',
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
'settings.agents.editor.toolsDone': 'Done',
'settings.agents.editor.builtInReadonly':
'Built-in agents cant be edited. You can enable, disable, or reset them from the agents list.',
};
export default en;
+66 -7
View File
@@ -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 */}
+18
View File
@@ -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",
+39 -1
View File
@@ -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,
+20 -1
View File
@@ -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,
+41
View File
@@ -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"));
}
}
+10
View File
@@ -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.
+39
View File
@@ -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();
}