diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index b4adbbfa7..7f96cfa8a 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -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]; diff --git a/app/src/components/settings/panels/ComposioTriagePanel.tsx b/app/src/components/settings/panels/ComposioTriagePanel.tsx new file mode 100644 index 000000000..e8745de37 --- /dev/null +++ b/app/src/components/settings/panels/ComposioTriagePanel.tsx @@ -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 | 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 ( +
+ +
+

Loading…

+
+
+ ); + } + + return ( +
+ + +
+

+ 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{' '} + OPENHUMAN_TRIGGER_TRIAGE_DISABLED is set, it overrides + these settings and disables triage for all triggers. +

+ + {/* Global toggle */} +
+ +
+ + {/* Per-toolkit list */} +
+ +

+ Comma-separated integration slugs, e.g. gmail, slack. + Case-insensitive. +

+ 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" + /> +
+ + + + {saveStatus === 'saved' && ( +

Settings saved

+ )} + {saveStatus === 'error' && ( +

Failed to save. Try again.

+ )} +
+
+ ); +}; + +export default ComposioTriagePanel; diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index c89de8e22..08a41a8c6 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -185,6 +185,28 @@ const developerItems = [ ), }, + { + id: 'composio-triggers', + title: 'Integration Triggers', + description: 'Configure AI triage settings for Composio integration triggers', + route: 'composio-triggers', + icon: ( + + + + + ), + }, ]; type SentryTestStatus = diff --git a/app/src/components/settings/panels/__tests__/ComposioTriagePanel.test.tsx b/app/src/components/settings/panels/__tests__/ComposioTriagePanel.test.tsx new file mode 100644 index 000000000..01fba128a --- /dev/null +++ b/app/src/components/settings/panels/__tests__/ComposioTriagePanel.test.tsx @@ -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 }) =>
{title}
, +})); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.queryByText('Loading…')).toBeNull(); + }); + + expect(screen.getByText('OPENHUMAN_TRIGGER_TRIAGE_DISABLED')).toBeInTheDocument(); + }); +}); diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index ab42a5151..73aebca75 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 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 = () => { )} /> } /> } /> + )} /> {/* About / updates */} )} /> {/* Fallback */} diff --git a/app/src/utils/tauriCommands/config.test.ts b/app/src/utils/tauriCommands/config.test.ts index 4c14dd1c9..161cbc9da 100644 --- a/app/src/utils/tauriCommands/config.test.ts +++ b/app/src/utils/tauriCommands/config.test.ts @@ -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('./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('./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']); + }); + }); }); diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index ead2c84f3..ae48e4401 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -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> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: 'openhuman.update_composio_trigger_settings', + params: update, + }); +} + +export async function openhumanGetComposioTriggerSettings(): Promise< + CommandResponse +> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc>({ + method: 'openhuman.get_composio_trigger_settings', + }); +} + export async function openhumanGetRuntimeFlags(): Promise> { if (!isTauri()) { throw new Error('Not running in Tauri'); diff --git a/src/openhuman/composio/bus.rs b/src/openhuman/composio/bus.rs index 0163fe4dc..cff919995 100644 --- a/src/openhuman/composio/bus.rs +++ b/src/openhuman/composio/bus.rs @@ -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 diff --git a/src/openhuman/composio/bus_tests.rs b/src/openhuman/composio/bus_tests.rs index 47f7a553c..30365c124 100644 --- a/src/openhuman/composio/bus_tests.rs +++ b/src/openhuman/composio/bus_tests.rs @@ -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(); diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index cecbd0671..a95339243 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -233,6 +233,14 @@ pub struct LocalAiSettingsPatch { pub usage_subconscious: Option, } +#[derive(Debug, Clone, Default)] +pub struct ComposioTriggerSettingsPatch { + /// When `Some(true)`, disables triage for all toolkits. + pub triage_disabled: Option, + /// When `Some(v)`, replaces the per-toolkit opt-out list entirely. + pub triage_disabled_toolkits: Option>, +} + #[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, 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, 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, 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, String> { let config = load_config_with_timeout().await?; diff --git a/src/openhuman/config/schema/tools.rs b/src/openhuman/config/schema/tools.rs index 3144f1a77..08f277758 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -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, } 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(), } } } diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index f3449d2bb..dfac70c07 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -113,6 +113,12 @@ struct VoiceServerSettingsUpdate { custom_dictionary: Option>, } +#[derive(Debug, Deserialize)] +struct ComposioTriggerSettingsUpdate { + triage_disabled: Option, + triage_disabled_toolkits: Option>, +} + pub fn all_controller_schemas() -> Vec { vec![ schemas("get_config"), @@ -140,6 +146,8 @@ pub fn all_controller_schemas() -> Vec { 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 { 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) -> ControllerFutu }) } +fn handle_update_composio_trigger_settings(params: Map) -> ControllerFuture { + Box::pin(async move { + log::debug!("[config][rpc] update_composio_trigger_settings enter"); + let update = match deserialize_params::(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) -> 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(params: Map) -> Result { serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) }