Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5d1dc64
docs(spec): UI-configurable max_actions_per_hour design
May 22, 2026
7646ccc
docs(plan): max_actions_per_hour UI implementation plan
May 22, 2026
7b03e1f
feat(config): add AutonomySettingsPatch + apply_autonomy_settings
May 22, 2026
678ba50
feat(config): add load_and_apply_autonomy_settings roundtrip
May 22, 2026
0d09c22
feat(config): add autonomy_settings schemas + DTO
May 22, 2026
8dc8032
feat(config): add autonomy_settings handlers + register controllers
May 22, 2026
fe5a095
test(rpc): roundtrip for config_*_autonomy_settings
May 22, 2026
b1e1213
chore(test): remove debug prints, use assert_jsonrpc_error in autonom…
May 22, 2026
97e67a1
feat(app): add autonomy_settings RPC method constants
May 22, 2026
29cb561
feat(app): add openhuman{Get,Update}AutonomySettings wrappers
May 22, 2026
b828ce6
test(app): cover openhuman{Get,Update}AutonomySettings wrappers
May 22, 2026
5e1b399
feat(app): add AutonomyPanel for max_actions_per_hour control
May 22, 2026
4a12043
feat(app): route + menu link for AutonomyPanel
May 22, 2026
40bc399
test(app): cover AutonomyPanel load/save/validate/error paths
May 22, 2026
ea6e509
test(e2e): persist autonomy max_actions_per_hour through core RPC
May 22, 2026
ea06caf
style: prettier + cargo fmt fixups for autonomy_settings files
May 22, 2026
d6f88b1
i18n: add autonomy keys to all locale-5 chunks for parity
May 22, 2026
c9d679b
fix(app): clarify autonomy helper text — cron + channels need restart
May 22, 2026
59c8e5d
chore: stop tracking superpowers-generated spec + plan
May 22, 2026
99886da
fix(app): address CodeRabbit review feedback
May 22, 2026
cf55cf7
chore: retrigger CI to confirm core_process test flake under llvm-cov
May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/components/settings/hooks/useSettingsNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type SettingsRoute =
| 'team-members'
| 'team-invites'
| 'developer-options'
| 'autonomy'
| 'ai'
| 'llm'
| 'voice'
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions app/src/components/settings/panels/AutonomyPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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 };

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;
16 changes: 16 additions & 0 deletions app/src/components/settings/panels/DeveloperOptionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
103 changes: 103 additions & 0 deletions app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/ar-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,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': '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':
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/bn-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,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': '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':
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/de-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ 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 autonomy',
'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds.',
'settings.developerMenu.integrationTriggers.title': 'Integrationsauslöser',
'settings.developerMenu.integrationTriggers.desc':
'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/en-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,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':
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/es-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,8 @@ 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': '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':
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/fr-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ 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': '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':
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/hi-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,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': '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':
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/id-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ const id5: 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':
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/it-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,8 @@ 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': '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':
Expand Down
Loading
Loading