feat(app): UI control for max_actions_per_hour (#2486) (#2500)

Co-authored-by: Chen Qian <cq@Chens-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chen Qian
2026-05-23 00:52:54 -07:00
committed by GitHub
co-authored by Chen Qian Claude Opus 4.7
parent 95aeab500e
commit 48548a223b
28 changed files with 810 additions and 2 deletions
@@ -16,6 +16,7 @@ export type SettingsRoute =
| 'team-members'
| 'team-invites'
| 'developer-options'
| 'autonomy'
| 'ai'
| 'llm'
| 'voice'
@@ -92,6 +93,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/privacy')) return 'privacy';
if (path.includes('/settings/billing')) return 'billing';
if (path.includes('/settings/developer-options')) return 'developer-options';
if (path.includes('/settings/autonomy')) return 'autonomy';
if (path.includes('/settings/llm')) return 'llm';
if (path.includes('/settings/ai')) return 'ai';
if (path.includes('/settings/local-model-debug')) return 'local-model-debug';
@@ -222,6 +224,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'composio-routing':
case 'notification-routing':
case 'mcp-server':
case 'autonomy':
return [settingsCrumb, developerCrumb];
// Developer options section page
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import {
openhumanGetAutonomySettings,
openhumanUpdateAutonomySettings,
} from '../../../utils/tauriCommands/config';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const PRESETS = [
{ label: '20 (default)', value: 20 },
{ label: '100', value: 100 },
{ label: '500', value: 500 },
{ label: '1000', value: 1000 },
];
const MIN = 1;
const MAX = 10_000;
type Status =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'saving' }
| { kind: 'saved' }
| { kind: 'error'; message: string };
/**
* Settings panel under Developer Options for editing the agent's
* max_actions_per_hour rate-limit. Loads the current value via
* openhumanGetAutonomySettings on mount; saving writes through
* openhumanUpdateAutonomySettings and persists to the user's config.toml.
* New value applies to the next agent session.
*/
const AutonomyPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [committed, setCommitted] = useState<number | null>(null);
const [draft, setDraft] = useState<string>('');
const [status, setStatus] = useState<Status>({ kind: 'loading' });
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await openhumanGetAutonomySettings();
if (cancelled) return;
const value = res.result.max_actions_per_hour;
setCommitted(value);
setDraft(String(value));
setStatus({ kind: 'idle' });
} catch (err) {
if (cancelled) return;
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
}
})();
return () => {
cancelled = true;
};
}, []);
const trimmed = draft.trim();
const parsed = Number(trimmed);
const isValid =
/^\d+$/.test(trimmed) && Number.isInteger(parsed) && parsed >= MIN && parsed <= MAX;
const isChanged = committed !== null && parsed !== committed;
const canSave = isValid && isChanged && status.kind !== 'saving';
const applyPreset = (value: number) => {
setDraft(String(value));
if (status.kind === 'saved' || status.kind === 'error') {
setStatus({ kind: 'idle' });
}
};
const onSave = async () => {
if (!canSave) return;
setStatus({ kind: 'saving' });
try {
await openhumanUpdateAutonomySettings({ max_actions_per_hour: parsed });
setCommitted(parsed);
setStatus({ kind: 'saved' });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Revert UI to last committed value, then surface the error.
if (committed !== null) setDraft(String(committed));
setStatus({ kind: 'error', message });
}
};
return (
<div className="z-10 relative">
<SettingsHeader
title="Agent autonomy"
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 flex flex-col gap-4">
<section className="px-4 py-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<label
htmlFor="autonomy-max-actions"
className="block text-sm font-semibold text-stone-900 dark:text-neutral-100">
Max actions per hour
</label>
<p className="text-xs text-stone-600 dark:text-neutral-400 mt-1">
Maximum tool actions an agent can run per rolling hour. New value applies to your next
chat. Cron jobs and channel listeners keep their current limit until you restart
OpenHuman.
</p>
<div className="mt-3 flex items-center gap-2">
<input
id="autonomy-max-actions"
type="number"
min={MIN}
max={MAX}
step={1}
value={draft}
onChange={e => {
setDraft(e.target.value);
if (status.kind === 'saved' || status.kind === 'error') {
setStatus({ kind: 'idle' });
}
}}
disabled={status.kind === 'loading' || status.kind === 'saving'}
className="w-32 px-3 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm font-mono"
/>
<button
onClick={onSave}
disabled={!canSave}
className="px-3 py-1.5 rounded-md bg-primary-600 hover:bg-primary-500 disabled:opacity-50 text-white text-xs font-medium transition-colors">
{status.kind === 'saving' ? 'Saving…' : 'Save'}
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{PRESETS.map(p => (
<button
key={p.value}
onClick={() => applyPreset(p.value)}
className="px-2 py-1 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800">
{p.label}
</button>
))}
</div>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="mt-3 text-xs min-h-[1rem]">
{!isValid && draft.trim() !== '' && (
<span className="text-coral-600 dark:text-coral-300">
Must be an integer between {MIN} and {MAX.toLocaleString()}.
</span>
)}
{status.kind === 'saved' && (
<span className="text-sage-700 dark:text-sage-300">Saved.</span>
)}
{status.kind === 'error' && (
<span className="text-coral-600 dark:text-coral-300">Failed: {status.message}</span>
)}
</div>
</section>
</div>
</div>
);
};
export default AutonomyPanel;
@@ -253,6 +253,22 @@ const developerItems = [
</svg>
),
},
{
id: 'autonomy',
titleKey: 'settings.developerMenu.autonomy.title',
descriptionKey: 'settings.developerMenu.autonomy.desc',
route: 'autonomy',
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"
/>
</svg>
),
},
];
const CoreModeBadge = () => {
@@ -0,0 +1,103 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import {
openhumanGetAutonomySettings,
openhumanUpdateAutonomySettings,
} from '../../../../utils/tauriCommands/config';
import AutonomyPanel from '../AutonomyPanel';
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToSettings: vi.fn(),
breadcrumbs: [],
}),
}));
vi.mock('../../../../utils/tauriCommands/config', async () => {
const actual = await vi.importActual<typeof import('../../../../utils/tauriCommands/config')>(
'../../../../utils/tauriCommands/config'
);
return {
...actual,
openhumanGetAutonomySettings: vi.fn(),
openhumanUpdateAutonomySettings: vi.fn(),
};
});
const mockGet = vi.mocked(openhumanGetAutonomySettings);
const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings);
describe('AutonomyPanel', () => {
beforeEach(() => {
mockGet.mockReset();
mockUpdate.mockReset();
});
test('loads the current value on mount', async () => {
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = (await screen.findByLabelText(/Max actions per hour/i)) as HTMLInputElement;
await waitFor(() => expect(input).toHaveValue(250));
});
test('Save is disabled until the value changes', async () => {
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const saveBtn = await screen.findByRole('button', { name: /^Save$/ });
expect(saveBtn).toBeDisabled();
const input = await screen.findByDisplayValue('20');
fireEvent.change(input, { target: { value: '100' } });
expect(saveBtn).not.toBeDisabled();
});
test('Save invokes the wrapper and shows confirmation', async () => {
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
mockUpdate.mockResolvedValue({
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
logs: [],
});
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = await screen.findByDisplayValue('20');
fireEvent.change(input, { target: { value: '300' } });
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith({ max_actions_per_hour: 300 }));
await screen.findByText(/Saved\./i);
});
test('shows inline validation when the value is out of range', async () => {
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = await screen.findByDisplayValue('20');
fireEvent.change(input, { target: { value: '0' } });
await screen.findByText(/Must be an integer between 1 and 10,000/i);
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
});
// Note: '12abc' is omitted because <input type="number"> filters non-numeric
// characters before React sees the change event — there's no way the panel
// can receive that input through normal UI flow.
test.each(['1.5', '1e2', '-5', '0.0'])('rejects non-integer input %s', async value => {
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 20 }, logs: [] });
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = await screen.findByDisplayValue('20');
fireEvent.change(input, { target: { value } });
await screen.findByText(/Must be an integer between 1 and 10,000/i);
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
});
test('surfaces RPC errors and reverts to the last committed value', async () => {
mockGet.mockResolvedValue({ result: { max_actions_per_hour: 50 }, logs: [] });
mockUpdate.mockRejectedValue(new Error('disk full'));
renderWithProviders(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
const input = (await screen.findByDisplayValue('50')) as HTMLInputElement;
fireEvent.change(input, { target: { value: '500' } });
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await screen.findByText(/Failed: disk full/);
// Reverted to last committed value.
expect(input).toHaveValue(50);
});
});
+2
View File
@@ -479,6 +479,8 @@ const ar5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'استقلالية الوكيل',
'settings.developerMenu.autonomy.desc': 'حدود معدل إجراءات الأدوات وعتبات الأمان',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+2
View File
@@ -485,6 +485,8 @@ const bn5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'এজেন্ট স্বায়ত্তশাসন',
'settings.developerMenu.autonomy.desc': 'টুল অ্যাকশনের রেট সীমা এবং নিরাপত্তা থ্রেশহোল্ড',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+3
View File
@@ -211,6 +211,9 @@ const de5: TranslationMap = {
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc':
'Konfiguriere externe MCP-Clients für die Verbindung mit OpenHuman',
'settings.developerMenu.autonomy.title': 'Agent-Autonomie',
'settings.developerMenu.autonomy.desc':
'Aktionsraten-Limits und Sicherheitsschwellen für Werkzeuge',
'settings.developerMenu.integrationTriggers.title': 'Integrationsauslöser',
'settings.developerMenu.integrationTriggers.desc':
'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser',
+2
View File
@@ -485,6 +485,8 @@ const en5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Agent autonomy',
'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+3
View File
@@ -490,6 +490,9 @@ const es5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Autonomía del agente',
'settings.developerMenu.autonomy.desc':
'Límites de frecuencia de acciones de herramientas y umbrales de seguridad',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+3
View File
@@ -494,6 +494,9 @@ const fr5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Autonomie de lagent',
'settings.developerMenu.autonomy.desc':
'Limites de fréquence des actions des outils et seuils de sécurité',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+2
View File
@@ -487,6 +487,8 @@ const hi5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'एजेंट स्वायत्तता',
'settings.developerMenu.autonomy.desc': 'टूल क्रिया दर सीमाएँ और सुरक्षा सीमाएँ',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+2
View File
@@ -488,6 +488,8 @@ const id5: TranslationMap = {
'settings.developerMenu.mcpServer.title': 'Server MCP',
'settings.developerMenu.mcpServer.desc':
'Konfigurasikan klien MCP eksternal untuk terhubung ke OpenHuman',
'settings.developerMenu.autonomy.title': 'Otonomi agen',
'settings.developerMenu.autonomy.desc': 'Batas laju aksi alat dan ambang keamanan',
'settings.mcpServer.title': 'Server MCP',
'settings.mcpServer.toolsSectionTitle': 'Alat yang tersedia',
'settings.mcpServer.toolsSectionDesc':
+3
View File
@@ -491,6 +491,9 @@ const it5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Autonomia agente',
'settings.developerMenu.autonomy.desc':
'Limiti di frequenza delle azioni degli strumenti e soglie di sicurezza',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+2
View File
@@ -448,6 +448,8 @@ const ko5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': '에이전트 자율성',
'settings.developerMenu.autonomy.desc': '도구 작업 속도 제한 및 안전 임계값',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+3
View File
@@ -491,6 +491,9 @@ const pt5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Autonomia do agente',
'settings.developerMenu.autonomy.desc':
'Limites de taxa de ações de ferramentas e limites de segurança',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+3
View File
@@ -488,6 +488,9 @@ const ru5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Автономия агента',
'settings.developerMenu.autonomy.desc':
'Ограничения частоты действий инструментов и пороги безопасности',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+2
View File
@@ -460,6 +460,8 @@ const zhCN5: TranslationMap = {
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP 服务器',
'settings.developerMenu.mcpServer.desc': '配置外部 MCP 客户端以连接到 OpenHuman',
'settings.developerMenu.autonomy.title': '智能体自主权',
'settings.developerMenu.autonomy.desc': '工具操作速率限制和安全阈值',
'settings.mcpServer.title': 'MCP 服务器',
'settings.mcpServer.toolsSectionTitle': '可用工具',
'settings.mcpServer.toolsSectionDesc':
+2
View File
@@ -1963,6 +1963,8 @@ const en: TranslationMap = {
'Configure AI triage settings for Composio integration triggers',
'settings.developerMenu.mcpServer.title': 'MCP Server',
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Agent autonomy',
'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
+2
View File
@@ -7,6 +7,7 @@ import AIPanel from '../components/settings/panels/AIPanel';
import AppearancePanel from '../components/settings/panels/AppearancePanel';
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
import AutocompletePanel from '../components/settings/panels/AutocompletePanel';
import AutonomyPanel from '../components/settings/panels/AutonomyPanel';
import BillingPanel from '../components/settings/panels/BillingPanel';
import CompanionPanel from '../components/settings/panels/CompanionPanel';
import ComposioPanel from '../components/settings/panels/ComposioPanel';
@@ -353,6 +354,7 @@ const Settings = () => {
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
{/* Developer Options */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route path="autonomy" element={wrapSettingsPage(<AutonomyPanel />)} />
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
<Route
path="notification-routing"
+2
View File
@@ -1,10 +1,12 @@
export const CORE_RPC_METHODS = {
configGet: 'openhuman.config_get',
configGetAnalyticsSettings: 'openhuman.config_get_analytics_settings',
configGetAutonomySettings: 'openhuman.config_get_autonomy_settings',
configGetComposioTriggerSettings: 'openhuman.config_get_composio_trigger_settings',
configGetRuntimeFlags: 'openhuman.config_get_runtime_flags',
configSetBrowserAllowAll: 'openhuman.config_set_browser_allow_all',
configUpdateAnalyticsSettings: 'openhuman.config_update_analytics_settings',
configUpdateAutonomySettings: 'openhuman.config_update_autonomy_settings',
configUpdateBrowserSettings: 'openhuman.config_update_browser_settings',
configUpdateComposioTriggerSettings: 'openhuman.config_update_composio_trigger_settings',
configUpdateLocalAiSettings: 'openhuman.config_update_local_ai_settings',
+45 -2
View File
@@ -9,17 +9,21 @@ vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
describe('tauriCommands/config', () => {
const mockIsTauri = isTauri as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
let openhumanGetAutonomySettings: typeof import('./config').openhumanGetAutonomySettings;
let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings;
let openhumanUpdateAutonomySettings: typeof import('./config').openhumanUpdateAutonomySettings;
let openhumanUpdateLocalAiSettings: typeof import('./config').openhumanUpdateLocalAiSettings;
let openhumanUpdateMeetSettings: typeof import('./config').openhumanUpdateMeetSettings;
let openhumanGetMeetSettings: typeof import('./config').openhumanGetMeetSettings;
beforeEach(async () => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
const actual = await vi.importActual<typeof import('./config')>('./config');
openhumanGetAutonomySettings = actual.openhumanGetAutonomySettings;
openhumanGetMeetSettings = actual.openhumanGetMeetSettings;
openhumanUpdateAutonomySettings = actual.openhumanUpdateAutonomySettings;
openhumanUpdateLocalAiSettings = actual.openhumanUpdateLocalAiSettings;
openhumanUpdateMeetSettings = actual.openhumanUpdateMeetSettings;
openhumanGetMeetSettings = actual.openhumanGetMeetSettings;
});
afterEach(() => {
@@ -97,6 +101,45 @@ describe('tauriCommands/config', () => {
});
});
describe('openhumanUpdateAutonomySettings', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 })).rejects.toThrow(
'Not running in Tauri'
);
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('forwards the patch to openhuman.config_update_autonomy_settings', async () => {
mockCallCoreRpc.mockResolvedValue({
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
logs: [],
});
await openhumanUpdateAutonomySettings({ max_actions_per_hour: 100 });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.config_update_autonomy_settings',
params: { max_actions_per_hour: 100 },
});
});
});
describe('openhumanGetAutonomySettings', () => {
test('throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanGetAutonomySettings()).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('reads via openhuman.config_get_autonomy_settings', async () => {
mockCallCoreRpc.mockResolvedValue({ result: { max_actions_per_hour: 250 }, logs: [] });
const out = await openhumanGetAutonomySettings();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.config_get_autonomy_settings',
});
expect(out.result.max_actions_per_hour).toBe(250);
});
});
describe('openhumanUpdateComposioTriggerSettings', () => {
let openhumanUpdateComposioTriggerSettings: typeof import('./config').openhumanUpdateComposioTriggerSettings;
+32
View File
@@ -355,6 +355,38 @@ export async function openhumanGetMeetSettings(): Promise<
});
}
/**
* Update the agent autonomy policy settings (currently just the per-hour tool
* action ceiling). Persists to the user's `config.toml`. Takes effect on the
* next agent session — running sessions / cron jobs / channel listeners keep
* the limit they were started with until core restart.
*/
export async function openhumanUpdateAutonomySettings(update: {
max_actions_per_hour?: number;
}): Promise<CommandResponse<ConfigSnapshot>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({
method: CORE_RPC_METHODS.configUpdateAutonomySettings,
params: update,
});
}
/**
* Read the current agent autonomy policy settings from the loaded config.
*/
export async function openhumanGetAutonomySettings(): Promise<
CommandResponse<{ max_actions_per_hour: number }>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<{ max_actions_per_hour: number }>>({
method: CORE_RPC_METHODS.configGetAutonomySettings,
});
}
export interface ComposioTriggerSettingsUpdate {
triage_disabled?: boolean | null;
triage_disabled_toolkits?: string[] | null;
@@ -98,6 +98,32 @@ describe('Settings - Advanced Config', () => {
);
});
it('persists autonomy max_actions_per_hour through core RPC', async function () {
this.timeout(60_000);
const before = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {});
expect(before.ok).toBe(true);
const current = before.result?.result?.max_actions_per_hour ?? 20;
// Pick a value different from the current one so the save actually mutates state.
const target = current === 250 ? 251 : 250;
await navigateViaHash('/settings/autonomy');
await waitForText('Agent autonomy', 15_000);
const input = await browser.$('#autonomy-max-actions');
await input.waitForExist({ timeout: 10_000 });
await input.setValue(String(target));
await clickText('Save', 10_000);
await waitForText('Saved.', 10_000);
await browser.waitUntil(
async () => {
const after = await callOpenhumanRpc('openhuman.config_get_autonomy_settings', {});
return after.ok && after.result?.result?.max_actions_per_hour === target;
},
{ timeout: 15_000, interval: 500, timeoutMsg: 'autonomy setting did not persist' }
);
});
it('switches composio routing mode to direct and can return to backend mode', async function () {
this.timeout(60_000);
await navigateViaHash('/settings/composio-routing');
+38
View File
@@ -374,6 +374,11 @@ pub struct MeetSettingsPatch {
pub auto_orchestrator_handoff: Option<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct AutonomySettingsPatch {
pub max_actions_per_hour: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct LocalAiSettingsPatch {
pub runtime_enabled: Option<bool>,
@@ -813,6 +818,39 @@ pub async fn load_and_apply_meet_settings(
apply_meet_settings(&mut config, update).await
}
/// Updates the autonomy policy settings in the configuration.
/// Validation: 1 <= max_actions_per_hour <= 10_000.
pub async fn apply_autonomy_settings(
config: &mut Config,
update: AutonomySettingsPatch,
) -> Result<RpcOutcome<serde_json::Value>, String> {
if let Some(v) = update.max_actions_per_hour {
if v == 0 || v > 10_000 {
return Err(format!(
"max_actions_per_hour must be between 1 and 10000 (got {v})"
));
}
config.autonomy.max_actions_per_hour = v;
}
config.save().await.map_err(|e| e.to_string())?;
let snapshot = snapshot_config_json(config)?;
Ok(RpcOutcome::new(
snapshot,
vec![format!(
"autonomy settings saved to {}",
config.config_path.display()
)],
))
}
/// Loads the configuration, applies autonomy settings updates, and saves it.
pub async fn load_and_apply_autonomy_settings(
update: AutonomySettingsPatch,
) -> Result<RpcOutcome<serde_json::Value>, String> {
let mut config = load_config_with_timeout().await?;
apply_autonomy_settings(&mut config, update).await
}
/// Loads the configuration, applies browser settings updates, and saves it.
pub async fn load_and_apply_browser_settings(
update: BrowserSettingsPatch,
+99
View File
@@ -1128,3 +1128,102 @@ async fn apply_screen_intelligence_settings_clamps_baseline_fps() {
.expect("low clamp");
assert!((cfg.screen_intelligence.baseline_fps - 0.2).abs() < f32::EPSILON);
}
// ── apply_autonomy_settings ────────────────────────────────────
#[tokio::test]
async fn apply_autonomy_settings_persists_max_actions_per_hour() {
let tmp = tempdir().unwrap();
let mut cfg = tmp_config(&tmp);
let outcome = apply_autonomy_settings(
&mut cfg,
AutonomySettingsPatch {
max_actions_per_hour: Some(200),
},
)
.await
.expect("apply");
assert_eq!(cfg.autonomy.max_actions_per_hour, 200);
// Snapshot returned so the caller can echo the saved state.
assert!(outcome.value.get("config").is_some());
// Round-trip from disk: reload the saved TOML and confirm.
let on_disk = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
assert!(
on_disk.contains("max_actions_per_hour = 200"),
"expected TOML to contain max_actions_per_hour = 200, got:\n{on_disk}"
);
}
#[tokio::test]
async fn apply_autonomy_settings_no_op_when_patch_empty() {
let tmp = tempdir().unwrap();
let mut cfg = tmp_config(&tmp);
let prior = cfg.autonomy.max_actions_per_hour;
let _ = apply_autonomy_settings(
&mut cfg,
AutonomySettingsPatch {
max_actions_per_hour: None,
},
)
.await
.expect("apply noop");
assert_eq!(cfg.autonomy.max_actions_per_hour, prior);
}
#[tokio::test]
async fn apply_autonomy_settings_rejects_zero() {
let tmp = tempdir().unwrap();
let mut cfg = tmp_config(&tmp);
let err = apply_autonomy_settings(
&mut cfg,
AutonomySettingsPatch {
max_actions_per_hour: Some(0),
},
)
.await
.unwrap_err();
assert!(
err.contains("between 1 and 10000"),
"expected validation error, got: {err}"
);
}
#[tokio::test]
async fn apply_autonomy_settings_rejects_above_cap() {
let tmp = tempdir().unwrap();
let mut cfg = tmp_config(&tmp);
let err = apply_autonomy_settings(
&mut cfg,
AutonomySettingsPatch {
max_actions_per_hour: Some(10_001),
},
)
.await
.unwrap_err();
assert!(err.contains("between 1 and 10000"));
}
#[tokio::test]
async fn load_and_apply_autonomy_settings_roundtrip() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempdir().unwrap();
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
let patch = AutonomySettingsPatch {
max_actions_per_hour: Some(500),
};
let outcome = load_and_apply_autonomy_settings(patch)
.await
.expect("apply");
assert!(outcome.value.get("config").is_some());
// Reload from scratch and confirm the saved value sticks.
let reloaded = load_config_with_timeout().await.expect("reload");
assert_eq!(reloaded.autonomy.max_actions_per_hour, 500);
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
+94
View File
@@ -121,6 +121,11 @@ struct MeetSettingsUpdate {
auto_orchestrator_handoff: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AutonomySettingsUpdate {
max_actions_per_hour: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct LocalAiSettingsUpdate {
runtime_enabled: Option<bool>,
@@ -206,6 +211,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("get_analytics_settings"),
schemas("update_meet_settings"),
schemas("get_meet_settings"),
schemas("update_autonomy_settings"),
schemas("get_autonomy_settings"),
schemas("agent_server_status"),
schemas("reset_local_data"),
schemas("get_data_paths"),
@@ -290,6 +297,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("get_meet_settings"),
handler: handle_get_meet_settings,
},
RegisteredController {
schema: schemas("update_autonomy_settings"),
handler: handle_update_autonomy_settings,
},
RegisteredController {
schema: schemas("get_autonomy_settings"),
handler: handle_get_autonomy_settings,
},
RegisteredController {
schema: schemas("agent_server_status"),
handler: handle_agent_server_status,
@@ -692,6 +707,31 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"update_autonomy_settings" => ControllerSchema {
namespace: "config",
function: "update_autonomy_settings",
description:
"Update agent autonomy policy settings (currently the per-hour tool action ceiling).",
inputs: vec![FieldSchema {
name: "max_actions_per_hour",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Maximum tool actions an agent may run per rolling hour (1-10000).",
required: false,
}],
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
},
"get_autonomy_settings" => ControllerSchema {
namespace: "config",
function: "get_autonomy_settings",
description: "Read current agent autonomy policy settings.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "max_actions_per_hour",
ty: TypeSchema::U64,
comment: "Current maximum tool actions per rolling hour.",
required: true,
}],
},
"agent_server_status" => ControllerSchema {
namespace: "config",
function: "agent_server_status",
@@ -1195,6 +1235,60 @@ fn handle_get_meet_settings(_params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_update_autonomy_settings(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!("[config][rpc] update_autonomy_settings enter");
let update = match deserialize_params::<AutonomySettingsUpdate>(params) {
Ok(u) => u,
Err(err) => {
log::warn!("[config][rpc] update_autonomy_settings invalid params: {err}");
return Err(err);
}
};
log::debug!(
"[config][rpc] update_autonomy_settings patch max_actions_per_hour={:?}",
update.max_actions_per_hour
);
let patch = config_rpc::AutonomySettingsPatch {
max_actions_per_hour: update.max_actions_per_hour,
};
match config_rpc::load_and_apply_autonomy_settings(patch).await {
Ok(outcome) => {
log::debug!("[config][rpc] update_autonomy_settings ok");
to_json(outcome)
}
Err(err) => {
log::warn!("[config][rpc] update_autonomy_settings failed: {err}");
Err(err)
}
}
})
}
fn handle_get_autonomy_settings(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async {
log::debug!("[config][rpc] get_autonomy_settings enter");
let config = match config_rpc::load_config_with_timeout().await {
Ok(c) => c,
Err(err) => {
log::warn!("[config][rpc] get_autonomy_settings load failed: {err}");
return Err(err);
}
};
let max_actions_per_hour = config.autonomy.max_actions_per_hour;
log::debug!(
"[config][rpc] get_autonomy_settings ok max_actions_per_hour={max_actions_per_hour}"
);
let result = serde_json::json!({
"max_actions_per_hour": max_actions_per_hour,
});
to_json(RpcOutcome::new(
result,
vec!["autonomy settings read".to_string()],
))
})
}
fn handle_agent_server_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::agent_server_status()) })
}
+55
View File
@@ -43,6 +43,8 @@ fn every_registered_key_resolves_to_non_unknown_schema() {
"get_analytics_settings",
"update_meet_settings",
"get_meet_settings",
"update_autonomy_settings",
"get_autonomy_settings",
"agent_server_status",
"reset_local_data",
"get_onboarding_completed",
@@ -217,3 +219,56 @@ fn default_onboarding_flag_constant_points_to_hidden_marker() {
// stays stable across refactors.
assert_eq!(DEFAULT_ONBOARDING_FLAG_NAME, ".skip_onboarding");
}
// ── autonomy settings handlers ───────────────────────────────
use crate::openhuman::config::TEST_ENV_LOCK;
#[tokio::test]
async fn handle_get_autonomy_settings_returns_current_value() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
// Seed a known value before reading.
let _ = crate::openhuman::config::ops::load_and_apply_autonomy_settings(
crate::openhuman::config::ops::AutonomySettingsPatch {
max_actions_per_hour: Some(123),
},
)
.await
.expect("seed");
let out = super::handle_get_autonomy_settings(serde_json::Map::new())
.await
.expect("handler");
// into_cli_compatible_json wraps data under "result" when logs are present.
let inner = out.get("result").unwrap_or(&out);
let value = inner.get("max_actions_per_hour").and_then(|v| v.as_u64());
assert_eq!(value, Some(123));
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
#[tokio::test]
async fn handle_update_autonomy_settings_rejects_invalid_value() {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
let mut params = serde_json::Map::new();
params.insert("max_actions_per_hour".into(), serde_json::json!(0));
let err = super::handle_update_autonomy_settings(params)
.await
.unwrap_err();
assert!(err.contains("between 1 and 10000"), "got: {err}");
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
+92
View File
@@ -6755,3 +6755,95 @@ async fn json_rpc_stale_auth_profile_lock_auto_recovered() {
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_config_autonomy_settings_roundtrip() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config_with_local_ai_disabled(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
// GET → expect the default (20).
let initial = post_json_rpc(
&rpc_base,
7001,
"openhuman.config_get_autonomy_settings",
json!({}),
)
.await;
let initial_outer = assert_no_jsonrpc_error(&initial, "get_autonomy_settings initial");
// assert_no_jsonrpc_error already strips the JSON-RPC envelope; one more hop
// strips the into_cli_compatible_json wrapper to reach the payload fields.
let initial_value = initial_outer
.get("result")
.and_then(|r| r.get("max_actions_per_hour"))
.and_then(Value::as_u64);
assert_eq!(
initial_value,
Some(20),
"expected default 20, got envelope: {initial_outer}"
);
// UPDATE → 250.
let update = post_json_rpc(
&rpc_base,
7002,
"openhuman.config_update_autonomy_settings",
json!({ "max_actions_per_hour": 250 }),
)
.await;
assert_no_jsonrpc_error(&update, "update_autonomy_settings");
// GET again → expect 250.
let after = post_json_rpc(
&rpc_base,
7003,
"openhuman.config_get_autonomy_settings",
json!({}),
)
.await;
let after_outer = assert_no_jsonrpc_error(&after, "get_autonomy_settings after");
let after_value = after_outer
.get("result")
.and_then(|r| r.get("max_actions_per_hour"))
.and_then(Value::as_u64);
assert_eq!(
after_value,
Some(250),
"expected 250 after update, got envelope: {after_outer}"
);
// Invalid value rejected — server returns JSON-RPC error envelope, not a result.
let bad = post_json_rpc(
&rpc_base,
7004,
"openhuman.config_update_autonomy_settings",
json!({ "max_actions_per_hour": 99999 }),
)
.await;
let bad_err = assert_jsonrpc_error(&bad, "update_autonomy_settings bad value");
let err_message = bad_err
.get("message")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("error object missing message: {bad_err}"));
assert!(
err_message.contains("between 1 and 10000"),
"expected validation error in: {err_message}"
);
mock_join.abort();
rpc_join.abort();
}