feat(composio): granular trigger triage settings — per-toolkit + global toggle (#1334)

This commit is contained in:
YellowSnnowmann
2026-05-07 12:31:40 -07:00
committed by GitHub
parent a3f8321303
commit 2c047a2153
12 changed files with 721 additions and 1 deletions
@@ -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();
});
});
+2
View File
@@ -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']);
});
});
});
+33
View File
@@ -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');
+39
View File
@@ -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
+54
View File
@@ -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();
+59
View File
@@ -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?;
+13
View File
@@ -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(),
}
}
}
+102
View File
@@ -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}"))
}