mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(composio): granular trigger triage settings — per-toolkit + global toggle (#1334)
This commit is contained in:
@@ -33,7 +33,8 @@ export type SettingsRoute =
|
||||
| 'notifications'
|
||||
| 'notification-routing'
|
||||
| 'intelligence'
|
||||
| 'webhooks-triggers';
|
||||
| 'webhooks-triggers'
|
||||
| 'composio-triggers';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
@@ -99,6 +100,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/memory-debug')) return 'memory-debug';
|
||||
if (path.includes('/settings/webhooks-debug')) return 'webhooks-debug';
|
||||
if (path.includes('/settings/webhooks-triggers')) return 'webhooks-triggers';
|
||||
if (path.includes('/settings/composio-triggers')) return 'composio-triggers';
|
||||
if (path.includes('/settings/intelligence')) return 'intelligence';
|
||||
if (path.includes('/settings/recovery-phrase')) return 'recovery-phrase';
|
||||
if (path.includes('/settings/agent-chat')) return 'agent-chat';
|
||||
@@ -214,6 +216,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
case 'memory-debug':
|
||||
case 'intelligence':
|
||||
case 'webhooks-triggers':
|
||||
case 'composio-triggers':
|
||||
case 'notification-routing':
|
||||
return [settingsCrumb, developerCrumb];
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
openhumanGetComposioTriggerSettings,
|
||||
openhumanUpdateComposioTriggerSettings,
|
||||
} from '../../../utils/tauriCommands';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const ComposioTriagePanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
const [triageDisabled, setTriageDisabled] = useState(false);
|
||||
const [disabledToolkits, setDisabledToolkits] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
const saveStatusTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
openhumanGetComposioTriggerSettings()
|
||||
.then(res => {
|
||||
if (!isMounted) return;
|
||||
const settings = res.result;
|
||||
if (!settings) return;
|
||||
setTriageDisabled(settings.triage_disabled ?? false);
|
||||
setDisabledToolkits((settings.triage_disabled_toolkits ?? []).join(', '));
|
||||
})
|
||||
.catch(err => {
|
||||
if (!isMounted) return;
|
||||
console.warn('[ComposioTriagePanel] failed to load settings:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (saveStatusTimer.current !== null) {
|
||||
clearTimeout(saveStatusTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const toolkitList = disabledToolkits
|
||||
.split(',')
|
||||
.map(t => t.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
await openhumanUpdateComposioTriggerSettings({
|
||||
triage_disabled: triageDisabled,
|
||||
triage_disabled_toolkits: toolkitList,
|
||||
});
|
||||
setSaveStatus('saved');
|
||||
if (saveStatusTimer.current !== null) {
|
||||
clearTimeout(saveStatusTimer.current);
|
||||
}
|
||||
saveStatusTimer.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
} catch (err) {
|
||||
console.warn('[ComposioTriagePanel] failed to save settings:', err);
|
||||
if (saveStatusTimer.current !== null) {
|
||||
clearTimeout(saveStatusTimer.current);
|
||||
saveStatusTimer.current = null;
|
||||
}
|
||||
setSaveStatus('error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Integration Triggers"
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-stone-500">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Integration Triggers"
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="p-4 space-y-5">
|
||||
<p className="text-sm text-stone-500">
|
||||
When active, each incoming Composio trigger runs through an AI triage step that classifies
|
||||
the event and may kick off automated actions — one local LLM turn per trigger. Disable
|
||||
globally or per integration if you prefer manual review. If the environment variable{' '}
|
||||
<span className="font-mono">OPENHUMAN_TRIGGER_TRIAGE_DISABLED</span> is set, it overrides
|
||||
these settings and disables triage for all triggers.
|
||||
</p>
|
||||
|
||||
{/* Global toggle */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50/60 p-4 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={triageDisabled}
|
||||
aria-label="Disable AI triage for all triggers"
|
||||
onClick={() => setTriageDisabled(v => !v)}
|
||||
className="w-full flex items-center justify-between">
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-stone-900">
|
||||
Disable AI triage for all triggers
|
||||
</span>
|
||||
<p className="text-xs text-stone-500 mt-0.5">
|
||||
Triggers are still recorded to history — no LLM turn is run.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-3 flex-shrink-0 w-9 h-5 rounded-full transition-colors relative ${
|
||||
triageDisabled ? 'bg-coral-400' : 'bg-stone-200'
|
||||
}`}>
|
||||
<div
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||
triageDisabled ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Per-toolkit list */}
|
||||
<div className={`space-y-2 ${triageDisabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||
<label className="block text-sm font-medium text-stone-800" htmlFor="disabled-toolkits">
|
||||
Disable AI triage for specific integrations
|
||||
</label>
|
||||
<p className="text-xs text-stone-500">
|
||||
Comma-separated integration slugs, e.g. <span className="font-mono">gmail, slack</span>.
|
||||
Case-insensitive.
|
||||
</p>
|
||||
<input
|
||||
id="disabled-toolkits"
|
||||
type="text"
|
||||
value={disabledToolkits}
|
||||
onChange={e => setDisabledToolkits(e.target.value)}
|
||||
placeholder="gmail, slack, ..."
|
||||
disabled={triageDisabled}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full py-2 rounded-xl bg-primary-600 text-white text-sm font-medium hover:bg-primary-500 transition-colors disabled:opacity-50">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
{saveStatus === 'saved' && (
|
||||
<p className="text-xs text-center text-green-600">Settings saved</p>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<p className="text-xs text-center text-red-500">Failed to save. Try again.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComposioTriagePanel;
|
||||
@@ -185,6 +185,28 @@ const developerItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'composio-triggers',
|
||||
title: 'Integration Triggers',
|
||||
description: 'Configure AI triage settings for Composio integration triggers',
|
||||
route: 'composio-triggers',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type SentryTestStatus =
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({ getSettings: vi.fn(), updateSettings: vi.fn() }));
|
||||
|
||||
vi.mock('../../../../utils/tauriCommands', () => ({
|
||||
openhumanGetComposioTriggerSettings: hoisted.getSettings,
|
||||
openhumanUpdateComposioTriggerSettings: hoisted.updateSettings,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({
|
||||
navigateBack: vi.fn(),
|
||||
navigateToSettings: vi.fn(),
|
||||
breadcrumbs: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/SettingsHeader', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
|
||||
}));
|
||||
|
||||
async function importPanel() {
|
||||
vi.resetModules();
|
||||
const mod = await import('../ComposioTriagePanel');
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
result: { triage_disabled: false, triage_disabled_toolkits: [] },
|
||||
logs: [],
|
||||
};
|
||||
|
||||
describe('ComposioTriagePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hoisted.getSettings.mockResolvedValue(defaultSettings);
|
||||
hoisted.updateSettings.mockResolvedValue({ result: {}, logs: [] });
|
||||
});
|
||||
|
||||
test('shows loading state then renders panel on successful fetch', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Integration Triggers')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Disable AI triage for all triggers')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gmail, slack, ...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('populates fields from fetched settings', async () => {
|
||||
hoisted.getSettings.mockResolvedValue({
|
||||
result: { triage_disabled: true, triage_disabled_toolkits: ['gmail', 'slack'] },
|
||||
logs: [],
|
||||
});
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText('Disable AI triage for all triggers');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
const input = screen.getByPlaceholderText('gmail, slack, ...');
|
||||
expect((input as HTMLInputElement).value).toBe('gmail, slack');
|
||||
});
|
||||
|
||||
test('toggle flips aria-checked and disables the input', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText('Disable AI triage for all triggers');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
const input = screen.getByPlaceholderText('gmail, slack, ...');
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
test('save calls updateSettings with correct params and shows saved status', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('gmail, slack, ...');
|
||||
fireEvent.change(input, { target: { value: 'Gmail, Slack' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Settings saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(hoisted.updateSettings).toHaveBeenCalledWith({
|
||||
triage_disabled: false,
|
||||
triage_disabled_toolkits: ['gmail', 'slack'],
|
||||
});
|
||||
});
|
||||
|
||||
test('shows error status when save fails', async () => {
|
||||
hoisted.updateSettings.mockRejectedValue(new Error('rpc error'));
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to save. Try again.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('handles fetch error gracefully (panel still renders)', async () => {
|
||||
hoisted.getSettings.mockRejectedValue(new Error('network error'));
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
// Panel still renders with defaults
|
||||
expect(screen.getByLabelText('Disable AI triage for all triggers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('env var note is visible in description', async () => {
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByText('OPENHUMAN_TRIGGER_TRIAGE_DISABLED')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import AIPanel from '../components/settings/panels/AIPanel';
|
||||
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
|
||||
import AutocompletePanel from '../components/settings/panels/AutocompletePanel';
|
||||
import BillingPanel from '../components/settings/panels/BillingPanel';
|
||||
import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel';
|
||||
import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel';
|
||||
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
|
||||
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
|
||||
@@ -299,6 +300,7 @@ const Settings = () => {
|
||||
<Route path="memory-debug" element={wrapSettingsPage(<MemoryDebugPanel />)} />
|
||||
<Route path="intelligence" element={<Intelligence />} />
|
||||
<Route path="webhooks-triggers" element={<Webhooks />} />
|
||||
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
|
||||
{/* About / updates */}
|
||||
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
|
||||
{/* Fallback */}
|
||||
|
||||
@@ -87,4 +87,62 @@ describe('tauriCommands/config', () => {
|
||||
expect(out.result.auto_orchestrator_handoff).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanUpdateComposioTriggerSettings', () => {
|
||||
let openhumanUpdateComposioTriggerSettings: typeof import('./config').openhumanUpdateComposioTriggerSettings;
|
||||
|
||||
beforeEach(async () => {
|
||||
const actual = await vi.importActual<typeof import('./config')>('./config');
|
||||
openhumanUpdateComposioTriggerSettings = actual.openhumanUpdateComposioTriggerSettings;
|
||||
});
|
||||
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
await expect(
|
||||
openhumanUpdateComposioTriggerSettings({ triage_disabled: true })
|
||||
).rejects.toThrow('Not running in Tauri');
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('forwards the patch to openhuman.update_composio_trigger_settings', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
|
||||
logs: [],
|
||||
});
|
||||
const patch = { triage_disabled: true, triage_disabled_toolkits: ['gmail', 'slack'] };
|
||||
await openhumanUpdateComposioTriggerSettings(patch);
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.update_composio_trigger_settings',
|
||||
params: patch,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openhumanGetComposioTriggerSettings', () => {
|
||||
let openhumanGetComposioTriggerSettings: typeof import('./config').openhumanGetComposioTriggerSettings;
|
||||
|
||||
beforeEach(async () => {
|
||||
const actual = await vi.importActual<typeof import('./config')>('./config');
|
||||
openhumanGetComposioTriggerSettings = actual.openhumanGetComposioTriggerSettings;
|
||||
});
|
||||
|
||||
test('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
await expect(openhumanGetComposioTriggerSettings()).rejects.toThrow('Not running in Tauri');
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reads via openhuman.get_composio_trigger_settings', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
result: { triage_disabled: false, triage_disabled_toolkits: ['slack'] },
|
||||
logs: [],
|
||||
});
|
||||
const out = await openhumanGetComposioTriggerSettings();
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.get_composio_trigger_settings',
|
||||
});
|
||||
expect(out.result.triage_disabled).toBe(false);
|
||||
expect(out.result.triage_disabled_toolkits).toEqual(['slack']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,6 +227,39 @@ export async function openhumanGetMeetSettings(): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export interface ComposioTriggerSettingsUpdate {
|
||||
triage_disabled?: boolean | null;
|
||||
triage_disabled_toolkits?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ComposioTriggerSettings {
|
||||
triage_disabled: boolean;
|
||||
triage_disabled_toolkits: string[];
|
||||
}
|
||||
|
||||
export async function openhumanUpdateComposioTriggerSettings(
|
||||
update: ComposioTriggerSettingsUpdate
|
||||
): Promise<CommandResponse<ConfigSnapshot>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ConfigSnapshot>>({
|
||||
method: 'openhuman.update_composio_trigger_settings',
|
||||
params: update,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanGetComposioTriggerSettings(): Promise<
|
||||
CommandResponse<ComposioTriggerSettings>
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ComposioTriggerSettings>>({
|
||||
method: 'openhuman.get_composio_trigger_settings',
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanGetRuntimeFlags(): Promise<CommandResponse<RuntimeFlags>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
|
||||
@@ -219,6 +219,45 @@ impl EventHandler for ComposioTriggerSubscriber {
|
||||
return;
|
||||
}
|
||||
|
||||
// Config-level triage gates — checked after env var so the env var
|
||||
// remains a global emergency kill-switch that works even when the
|
||||
// config file is corrupt. Fail-open on load error: if we can't read
|
||||
// the config we let triage run rather than silently drop events.
|
||||
match config_rpc::load_config_with_timeout().await {
|
||||
Ok(config) => {
|
||||
if config.composio.triage_disabled {
|
||||
tracing::debug!(
|
||||
toolkit = %toolkit,
|
||||
trigger = %trigger,
|
||||
"[composio][triage] skipped: composio.triage_disabled=true in config"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let toolkit_lower = toolkit.to_ascii_lowercase();
|
||||
if config
|
||||
.composio
|
||||
.triage_disabled_toolkits
|
||||
.iter()
|
||||
.any(|t| t.to_ascii_lowercase() == toolkit_lower)
|
||||
{
|
||||
tracing::debug!(
|
||||
toolkit = %toolkit,
|
||||
trigger = %trigger,
|
||||
"[composio][triage] skipped: toolkit in composio.triage_disabled_toolkits"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
toolkit = %toolkit,
|
||||
trigger = %trigger,
|
||||
error = %e,
|
||||
"[composio][triage] config load failed — falling through to triage (fail-open)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the envelope outside the spawned task so any panic in
|
||||
// `from_composio` surfaces on the bus dispatch thread (where
|
||||
// the broadcast subscriber loop can log it) rather than being
|
||||
|
||||
@@ -56,6 +56,60 @@ fn triage_disabled_flag_parser() {
|
||||
assert!(!triage_disabled(), "unset must default to triage enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composio_config_triage_disabled_default() {
|
||||
use crate::openhuman::config::ComposioConfig;
|
||||
let cfg = ComposioConfig::default();
|
||||
assert!(
|
||||
!cfg.triage_disabled,
|
||||
"triage_disabled must default to false"
|
||||
);
|
||||
assert!(
|
||||
cfg.triage_disabled_toolkits.is_empty(),
|
||||
"triage_disabled_toolkits must default to empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composio_config_triage_disabled_toolkit_match() {
|
||||
use crate::openhuman::config::ComposioConfig;
|
||||
let cfg = ComposioConfig {
|
||||
triage_disabled_toolkits: vec!["GMAIL".to_string(), "slack".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let toolkit = "gmail";
|
||||
let toolkit_lower = toolkit.to_ascii_lowercase();
|
||||
assert!(
|
||||
cfg.triage_disabled_toolkits
|
||||
.iter()
|
||||
.any(|t| t.to_ascii_lowercase() == toolkit_lower),
|
||||
"case-insensitive match against gmail should fire"
|
||||
);
|
||||
assert!(
|
||||
!cfg.triage_disabled_toolkits
|
||||
.iter()
|
||||
.any(|t| t.to_ascii_lowercase() == "github"),
|
||||
"github should not match"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_subscriber_skips_triage_when_env_disabled() {
|
||||
let _guard = TRIAGE_ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var(TRIAGE_DISABLED_ENV, "1");
|
||||
let sub = ComposioTriggerSubscriber::new();
|
||||
// Should complete without panicking (env gate fires, triage skipped).
|
||||
sub.handle(&DomainEvent::ComposioTriggerReceived {
|
||||
toolkit: "gmail".into(),
|
||||
trigger: "GMAIL_NEW_GMAIL_MESSAGE".into(),
|
||||
metadata_id: "trig-env".into(),
|
||||
metadata_uuid: "uuid-env".into(),
|
||||
payload: json!({ "subject": "env gate test" }),
|
||||
})
|
||||
.await;
|
||||
std::env::remove_var(TRIAGE_DISABLED_ENV);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handles_connection_created_event_without_panic() {
|
||||
let sub = ComposioConnectionCreatedSubscriber::new();
|
||||
|
||||
@@ -233,6 +233,14 @@ pub struct LocalAiSettingsPatch {
|
||||
pub usage_subconscious: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ComposioTriggerSettingsPatch {
|
||||
/// When `Some(true)`, disables triage for all toolkits.
|
||||
pub triage_disabled: Option<bool>,
|
||||
/// When `Some(v)`, replaces the per-toolkit opt-out list entirely.
|
||||
pub triage_disabled_toolkits: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RuntimeFlagsOut {
|
||||
pub browser_allow_all: bool,
|
||||
@@ -566,6 +574,57 @@ pub async fn load_and_apply_local_ai_settings(
|
||||
apply_local_ai_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
/// Updates the Composio trigger-triage settings in the configuration.
|
||||
pub async fn apply_composio_trigger_settings(
|
||||
config: &mut Config,
|
||||
update: ComposioTriggerSettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
if let Some(v) = update.triage_disabled {
|
||||
config.composio.triage_disabled = v;
|
||||
tracing::debug!(
|
||||
triage_disabled = v,
|
||||
"[config][composio] triage_disabled updated"
|
||||
);
|
||||
}
|
||||
if let Some(toolkits) = update.triage_disabled_toolkits {
|
||||
tracing::debug!(
|
||||
count = toolkits.len(),
|
||||
"[config][composio] triage_disabled_toolkits updated"
|
||||
);
|
||||
config.composio.triage_disabled_toolkits = toolkits;
|
||||
}
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
let snapshot = snapshot_config_json(config)?;
|
||||
Ok(RpcOutcome::new(
|
||||
snapshot,
|
||||
vec![format!(
|
||||
"composio trigger settings saved to {}",
|
||||
config.config_path.display()
|
||||
)],
|
||||
))
|
||||
}
|
||||
|
||||
/// Loads the configuration, applies composio trigger settings, and saves it.
|
||||
pub async fn load_and_apply_composio_trigger_settings(
|
||||
update: ComposioTriggerSettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let mut config = load_config_with_timeout().await?;
|
||||
apply_composio_trigger_settings(&mut config, update).await
|
||||
}
|
||||
|
||||
/// Reads the current composio trigger-triage settings.
|
||||
pub async fn get_composio_trigger_settings() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
let result = serde_json::json!({
|
||||
"triage_disabled": config.composio.triage_disabled,
|
||||
"triage_disabled_toolkits": config.composio.triage_disabled_toolkits,
|
||||
});
|
||||
Ok(RpcOutcome::new(
|
||||
result,
|
||||
vec!["composio trigger settings read".to_string()],
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolves the effective API URL from configuration or defaults.
|
||||
pub async fn load_and_resolve_api_url() -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
|
||||
@@ -248,6 +248,17 @@ pub struct ComposioConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_entity_id")]
|
||||
pub entity_id: String,
|
||||
/// When true, the triage pipeline is disabled for all Composio
|
||||
/// triggers. Triggers are still recorded to history.
|
||||
/// Overrides `triage_disabled_toolkits` when set.
|
||||
#[serde(default)]
|
||||
pub triage_disabled: bool,
|
||||
/// Per-toolkit triage opt-out list. Toolkit slugs listed here
|
||||
/// skip the LLM triage turn — triggers are still recorded to
|
||||
/// history. Case-insensitive match against the incoming toolkit
|
||||
/// field (e.g. `["gmail", "slack"]`).
|
||||
#[serde(default)]
|
||||
pub triage_disabled_toolkits: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_entity_id() -> String {
|
||||
@@ -259,6 +270,8 @@ impl Default for ComposioConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
entity_id: default_entity_id(),
|
||||
triage_disabled: false,
|
||||
triage_disabled_toolkits: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,12 @@ struct VoiceServerSettingsUpdate {
|
||||
custom_dictionary: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ComposioTriggerSettingsUpdate {
|
||||
triage_disabled: Option<bool>,
|
||||
triage_disabled_toolkits: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("get_config"),
|
||||
@@ -140,6 +146,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("update_dictation_settings"),
|
||||
schemas("get_voice_server_settings"),
|
||||
schemas("update_voice_server_settings"),
|
||||
schemas("update_composio_trigger_settings"),
|
||||
schemas("get_composio_trigger_settings"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -245,6 +253,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("update_voice_server_settings"),
|
||||
handler: handle_update_voice_server_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("update_composio_trigger_settings"),
|
||||
handler: handle_update_composio_trigger_settings,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_composio_trigger_settings"),
|
||||
handler: handle_get_composio_trigger_settings,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -652,6 +668,49 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"update_composio_trigger_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "update_composio_trigger_settings",
|
||||
description:
|
||||
"Update Composio trigger-triage settings. When triage is disabled the \
|
||||
local LLM is NOT invoked per trigger — events are still archived to \
|
||||
trigger history.",
|
||||
inputs: vec![
|
||||
optional_bool(
|
||||
"triage_disabled",
|
||||
"When true, skip the LLM triage turn for all Composio triggers globally.",
|
||||
),
|
||||
FieldSchema {
|
||||
name: "triage_disabled_toolkits",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
|
||||
TypeSchema::String,
|
||||
)))),
|
||||
comment: "Toolkit slugs that skip LLM triage (e.g. [\"gmail\", \"slack\"]).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output("snapshot", "Updated config snapshot.")],
|
||||
},
|
||||
"get_composio_trigger_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_composio_trigger_settings",
|
||||
description: "Read current Composio trigger-triage settings.",
|
||||
inputs: vec![],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "triage_disabled",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether the global triage-disabled flag is set.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "triage_disabled_toolkits",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
|
||||
comment: "Toolkit slugs that skip LLM triage.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "unknown",
|
||||
@@ -946,6 +1005,49 @@ fn handle_set_onboarding_completed(params: Map<String, Value>) -> ControllerFutu
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_update_composio_trigger_settings(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[config][rpc] update_composio_trigger_settings enter");
|
||||
let update = match deserialize_params::<ComposioTriggerSettingsUpdate>(params) {
|
||||
Ok(u) => u,
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_composio_trigger_settings invalid params: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let patch = config_rpc::ComposioTriggerSettingsPatch {
|
||||
triage_disabled: update.triage_disabled,
|
||||
triage_disabled_toolkits: update.triage_disabled_toolkits,
|
||||
};
|
||||
match config_rpc::load_and_apply_composio_trigger_settings(patch).await {
|
||||
Ok(outcome) => {
|
||||
log::debug!("[config][rpc] update_composio_trigger_settings ok");
|
||||
to_json(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] update_composio_trigger_settings failed: {err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_composio_trigger_settings(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
log::debug!("[config][rpc] get_composio_trigger_settings enter");
|
||||
match config_rpc::get_composio_trigger_settings().await {
|
||||
Ok(outcome) => {
|
||||
log::debug!("[config][rpc] get_composio_trigger_settings ok");
|
||||
to_json(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[config][rpc] get_composio_trigger_settings failed: {err}");
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user