diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index d7961f91b7..d9620fc552 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -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 diff --git a/app/src/components/settings/panels/AutonomyPanel.tsx b/app/src/components/settings/panels/AutonomyPanel.tsx new file mode 100644 index 0000000000..c373c154f2 --- /dev/null +++ b/app/src/components/settings/panels/AutonomyPanel.tsx @@ -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(null); + const [draft, setDraft] = useState(''); + const [status, setStatus] = useState({ 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 ( +
+ +
+
+ +

+ 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. +

+ +
+ { + 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" + /> + +
+ +
+ {PRESETS.map(p => ( + + ))} +
+ +
+ {!isValid && draft.trim() !== '' && ( + + Must be an integer between {MIN} and {MAX.toLocaleString()}. + + )} + {status.kind === 'saved' && ( + Saved. + )} + {status.kind === 'error' && ( + Failed: {status.message} + )} +
+
+
+
+ ); +}; + +export default AutonomyPanel; diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 908ba6b126..b2c15b7d90 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -253,6 +253,22 @@ const developerItems = [ ), }, + { + id: 'autonomy', + titleKey: 'settings.developerMenu.autonomy.title', + descriptionKey: 'settings.developerMenu.autonomy.desc', + route: 'autonomy', + icon: ( + + + + ), + }, ]; const CoreModeBadge = () => { diff --git a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx new file mode 100644 index 0000000000..b10b6f5583 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx @@ -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( + '../../../../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(, { 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(, { 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(, { 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(, { 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 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(, { 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(, { 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); + }); +}); diff --git a/app/src/lib/i18n/chunks/ar-5.ts b/app/src/lib/i18n/chunks/ar-5.ts index 6aad0a5e0e..448310af14 100644 --- a/app/src/lib/i18n/chunks/ar-5.ts +++ b/app/src/lib/i18n/chunks/ar-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/bn-5.ts b/app/src/lib/i18n/chunks/bn-5.ts index 79c9364569..e24e5c08a0 100644 --- a/app/src/lib/i18n/chunks/bn-5.ts +++ b/app/src/lib/i18n/chunks/bn-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 2bbee687c5..5ffd167b19 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -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', diff --git a/app/src/lib/i18n/chunks/en-5.ts b/app/src/lib/i18n/chunks/en-5.ts index 8cac51889f..6c170c15d5 100644 --- a/app/src/lib/i18n/chunks/en-5.ts +++ b/app/src/lib/i18n/chunks/en-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/es-5.ts b/app/src/lib/i18n/chunks/es-5.ts index dba785c5dd..8924861034 100644 --- a/app/src/lib/i18n/chunks/es-5.ts +++ b/app/src/lib/i18n/chunks/es-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/fr-5.ts b/app/src/lib/i18n/chunks/fr-5.ts index dbe9c12c44..7a7208b6e6 100644 --- a/app/src/lib/i18n/chunks/fr-5.ts +++ b/app/src/lib/i18n/chunks/fr-5.ts @@ -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 l’agent', + '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': diff --git a/app/src/lib/i18n/chunks/hi-5.ts b/app/src/lib/i18n/chunks/hi-5.ts index 7dd3617ff8..deb7ef9154 100644 --- a/app/src/lib/i18n/chunks/hi-5.ts +++ b/app/src/lib/i18n/chunks/hi-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/id-5.ts b/app/src/lib/i18n/chunks/id-5.ts index 70a6dcf4cf..e7d118def5 100644 --- a/app/src/lib/i18n/chunks/id-5.ts +++ b/app/src/lib/i18n/chunks/id-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/it-5.ts b/app/src/lib/i18n/chunks/it-5.ts index bed88b8134..93494cf583 100644 --- a/app/src/lib/i18n/chunks/it-5.ts +++ b/app/src/lib/i18n/chunks/it-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/ko-5.ts b/app/src/lib/i18n/chunks/ko-5.ts index 37c5fb8c77..85e1f7e2b3 100644 --- a/app/src/lib/i18n/chunks/ko-5.ts +++ b/app/src/lib/i18n/chunks/ko-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/pt-5.ts b/app/src/lib/i18n/chunks/pt-5.ts index 9a9ac5e88e..a0c045d5c6 100644 --- a/app/src/lib/i18n/chunks/pt-5.ts +++ b/app/src/lib/i18n/chunks/pt-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/ru-5.ts b/app/src/lib/i18n/chunks/ru-5.ts index c82e380d5a..fc302f03f0 100644 --- a/app/src/lib/i18n/chunks/ru-5.ts +++ b/app/src/lib/i18n/chunks/ru-5.ts @@ -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': diff --git a/app/src/lib/i18n/chunks/zh-CN-5.ts b/app/src/lib/i18n/chunks/zh-CN-5.ts index 149b85dc67..b40b6af934 100644 --- a/app/src/lib/i18n/chunks/zh-CN-5.ts +++ b/app/src/lib/i18n/chunks/zh-CN-5.ts @@ -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': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ce500411fb..078bae24af 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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': diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 20db4f13e9..a0cfce2770 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -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 = () => { )} /> {/* Developer Options */} )} /> + )} /> )} /> ({ 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('./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; diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 1faa5e9162..06bbe100bc 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -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> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + 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>({ + method: CORE_RPC_METHODS.configGetAutonomySettings, + }); +} + export interface ComposioTriggerSettingsUpdate { triage_disabled?: boolean | null; triage_disabled_toolkits?: string[] | null; diff --git a/app/test/e2e/specs/settings-advanced-config.spec.ts b/app/test/e2e/specs/settings-advanced-config.spec.ts index cc3d19f7fa..671e6ca2b6 100644 --- a/app/test/e2e/specs/settings-advanced-config.spec.ts +++ b/app/test/e2e/specs/settings-advanced-config.spec.ts @@ -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'); diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 23194da35c..77cd748ca5 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -385,6 +385,11 @@ pub struct MeetSettingsPatch { pub auto_orchestrator_handoff: Option, } +#[derive(Debug, Clone, Default)] +pub struct AutonomySettingsPatch { + pub max_actions_per_hour: Option, +} + #[derive(Debug, Clone, Default)] pub struct LocalAiSettingsPatch { pub runtime_enabled: Option, @@ -824,6 +829,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, 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, 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, diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 6b1f332b25..e638d67c29 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -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"); + } +} diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index 51a61c5385..526138c4fe 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -121,6 +121,11 @@ struct MeetSettingsUpdate { auto_orchestrator_handoff: Option, } +#[derive(Debug, Deserialize)] +struct AutonomySettingsUpdate { + max_actions_per_hour: Option, +} + #[derive(Debug, Deserialize)] struct LocalAiSettingsUpdate { runtime_enabled: Option, @@ -206,6 +211,8 @@ pub fn all_controller_schemas() -> Vec { 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 { 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) -> ControllerFuture { }) } +fn handle_update_autonomy_settings(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!("[config][rpc] update_autonomy_settings enter"); + let update = match deserialize_params::(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) -> 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) -> ControllerFuture { Box::pin(async { to_json(config_rpc::agent_server_status()) }) } diff --git a/src/openhuman/config/schemas_tests.rs b/src/openhuman/config/schemas_tests.rs index acdbc00da2..12d4947e15 100644 --- a/src/openhuman/config/schemas_tests.rs +++ b/src/openhuman/config/schemas_tests.rs @@ -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"); + } +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 4e92a16044..67499fb233 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -6759,3 +6759,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(); +}