feat(composio): per-toolkit tool curation, user scopes, and Gmail HTML→markdown (#643)

* feat(composio): add user scope management for toolkits

- Introduced `get_user_scopes` and `set_user_scopes` functions to manage per-toolkit user scope preferences, allowing for read, write, and admin classifications.
- Updated `all_controller_schemas` and `all_registered_controllers` to include new schemas for user scope management.
- Implemented `evaluate_tool_visibility` to determine tool visibility based on user-defined scopes, enhancing security and control over tool actions.
- Added `UserScopePref` struct to store user preferences and integrated it with memory storage for persistence.
- Enhanced existing tools to respect user scope preferences during execution, ensuring actions align with user-defined permissions.

These changes improve the flexibility and security of toolkit interactions, allowing users to customize their access levels for different actions.

* feat(gmail): expand GMAIL_CURATED tools for enhanced email management

- Updated the GMAIL_CURATED constant to include additional tools for reading and writing emails, such as GMAIL_LIST_MESSAGES, GMAIL_LIST_THREADS, GMAIL_GET_ATTACHMENT, and GMAIL_FORWARD_MESSAGE.
- Improved organization of tools by categorizing them under distinct sections for reading messages, managing drafts, and handling labels.
- Enhanced admin tools with new functionalities like GMAIL_BATCH_DELETE_MESSAGES and GMAIL_UNTRASH_THREAD, improving overall email management capabilities.

These changes provide a more comprehensive toolkit for interacting with Gmail, enhancing user experience and functionality.

* refactor(tool_scope, gmail): improve code formatting and organization

- Reformatted the `ADMIN` and `GMAIL_CURATED` constants for better readability by aligning the entries vertically.
- Enhanced the clarity of the `classify_unknown` function by improving the structure of the constants, making it easier to maintain and understand.
- Updated test assertions in `toolkit_from_slug` for consistency in formatting, ensuring clearer test outputs.

These changes improve the overall readability and maintainability of the codebase, aligning with ongoing refactoring efforts.

* feat(github): add GitHub toolkit and curated tools for enhanced integration

- Introduced a new GitHub provider module, including a curated catalog of GitHub actions tailored for common tasks such as repository management, issue tracking, and pull request handling.
- Implemented the `catalog_for_toolkit` function to allow fallback to a static curated list for toolkits without a native provider, ensuring consistent tool visibility and access.
- Updated the `evaluate_tool_visibility` function to prioritize curated tools from registered providers, enhancing the overall user experience and security by enforcing whitelist checks.

These changes expand the capabilities of the Composio toolkit, providing users with a comprehensive set of tools for interacting with GitHub, while maintaining a focus on user-defined permissions and visibility.

* refactor(tools): improve formatting and organization of curated tools

- Reformatted the `GITHUB_CURATED`, `NOTION_CURATED`, and other tool constants for better readability by aligning entries vertically.
- Enhanced the clarity of the code structure, making it easier to maintain and understand.
- Updated related documentation to reflect the changes in formatting and organization.

These changes improve the overall readability and maintainability of the codebase, aligning with ongoing refactoring efforts.

* feat(catalogs): introduce curated catalogs for various toolkits

- Added a new module `catalogs.rs` containing curated tool lists for Slack, Discord, Google Calendar, Google Drive, Google Docs, and more, enhancing the Composio toolkit's integration capabilities.
- Updated the `mod.rs` file to include the new `catalogs` module and modified the `catalog_for_toolkit` function to support these curated lists, allowing for better organization and access to toolkit actions.
- This addition improves the overall functionality and user experience by providing a comprehensive set of tools for interacting with popular platforms.

* feat(post-process): implement HTML to markdown conversion for Gmail responses

- Introduced a new `post_process` module to handle per-toolkit response modifications, specifically for converting HTML content to markdown format.
- Enhanced the `ComposioExecuteTool` to apply post-processing on successful responses, improving the clarity and usability of data returned from Gmail.
- Added tests to ensure the correct functionality of HTML detection and conversion, validating the integrity of the post-processing logic.

These changes enhance the user experience by streamlining the handling of HTML content in responses, making it more suitable for further processing and display.

* refactor(post_process): reorganize HTML detection constants for improved readability

- Moved HTML detection markers in the `looks_like_html` function to a more structured format, enhancing clarity and maintainability.
- This change aligns with ongoing efforts to improve code organization and readability within the post-processing module.

* refactor(catalogs): enhance formatting and organization of curated tool constants

- Reformatted the `SLACK_CURATED`, `DISCORD_CURATED`, and `GOOGLECALENDAR_CURATED` constants for improved readability by aligning entries vertically.
- This change enhances the clarity and maintainability of the code, aligning with ongoing refactoring efforts to improve code organization.

* feat(connect-modal): wire read/write/admin scope toggles to composio prefs

Adds the three scope toggles to the connected-state of the
ComposioConnectModal so users can gate which Composio actions the
agent may invoke per integration. Loads the stored pref via
`composio_get_user_scopes` once the modal lands in the connected
phase and persists changes through `composio_set_user_scopes` with
optimistic updates and rollback on error.

- Adds `getUserScopes` / `setUserScopes` to composioApi.
- Adds `ComposioUserScopePref` type mirror.
- Renders accessible role="switch" toggles with hint text per row.

* refactor(ComposioConnectModal): simplify SCOPE_ROWS definition

- Streamlined the definition of SCOPE_ROWS in ComposioConnectModal by removing unnecessary line breaks, enhancing code readability and maintainability.
- Updated the agent.toml configuration to include "composio_execute" in the tools list, expanding the capabilities of the integrations agent.

* fix(composio): apply curated whitelist + scope pref to integrations_agent prompt

The agent prompt for integrations_agent was rendering every action
returned by the backend's `composio_list_tools` for each connected
toolkit, bypassing the curation/scope filter that the meta-tool layer
applies. Concretely the GitHub integrations_agent prompt was showing
~500 actions including non-curated entries like
GITHUB_ADD_OR_UPDATE_TEAM_REPOSITORY_PERMISSIONS.

Adds `is_action_visible_with_pref(slug, pref)` — a sync helper that
mirrors the meta-tool layer's decision logic — and applies it in:
- `fetch_connected_integrations_uncached` (bulk session-cached path)
- `fetch_toolkit_actions` (per-toolkit spawn-time path)

One pref load per toolkit (not per action) keeps the cost minimal.

* refactor(fetch_connected_integrations): streamline action visibility filter

Simplified the action visibility filter in `fetch_connected_integrations_uncached` by consolidating the filter logic into a single line. This change enhances code readability while maintaining the existing functionality of applying user preferences to the displayed actions.

* fix(prompt): drop duplicate `### Available Tools` listing in text-mode preamble

Text-mode subagent prompts were rendering the tool catalog twice: once
in the prompt template's `## Tools` section (with the richer
`Call as: NAME[arg|arg]` signatures from `prompts::ToolsSection::build`)
and once in `### Available Tools` under `## Tool Use Protocol`
(`Parameters: name:type, ...` format).

For an integrations_agent toolkit spawn (~50 actions) this doubled the
tool listing bytes for no informational gain. Keep only the protocol
preamble (essential for text mode); the catalog stays in `## Tools`.

Removes `summarise_parameters` and `first_line_truncated` which were
the sole consumers, plus the now-unused `std::fmt::Write` import.

* review: address PR feedback (UTF-8 boundary, structured logs, doc, debug)

Real fixes:
- post_process: walk back to UTF-8 char boundary before truncating to
  4096 bytes; previous `&s[..4096]` could panic mid-codepoint. Adds
  regression test that places a 3-byte char straddling the cutoff.
- composioApi: move misplaced `execute` docstring back above `execute`
  (it had drifted above the new `getUserScopes`).
- schemas: include `get_user_scopes` / `set_user_scopes` in the
  `every_known_schema_key_resolves` test.

Diagnosability:
- schemas: structured `[composio:scopes]` debug/error logs at entry,
  exit, and every early-return in `handle_get_user_scopes` /
  `handle_set_user_scopes` (method + toolkit + pref fields). The
  memory-not-ready branch now logs an error before returning.
- composioApi + ConnectModal: grep-friendly `[composio][scopes]` debug
  logs around getUserScopes / setUserScopes RPC round-trips and the
  toggle handler (old → new state, persisted result, errors).
- user_scopes: load_or_default now logs the same normalized `key` that
  `load()` does, so traces correlate across both code paths.

Cleanups:
- tools: replace external `idx` + `Vec::retain` in
  `filter_list_tools_response` with a drain + zip + filter_map pattern.
- tool_scope: document the no-underscore-in-toolkit-name assumption of
  `toolkit_from_slug`, and call out the `microsoft_teams` alias.
- Cargo.toml: comment on the html2md vs htmd choice.

Pushback (no change):
- Reviewer asked to split the type-only import in ConnectModal into a
  separate `import type` line; ESLint's `no-duplicate-imports` rejects
  that, so the inline `type` form (functionally identical) stays.
This commit is contained in:
Steven Enamakel
2026-04-17 21:12:17 -07:00
committed by GitHub
parent 2150944cb6
commit 8f4696bdfb
24 changed files with 3967 additions and 109 deletions
Generated
+85 -5
View File
@@ -1134,7 +1134,7 @@ dependencies = [
"core-foundation-sys 0.8.7",
"coreaudio-rs",
"dasp_sample",
"jni",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
@@ -2409,6 +2409,34 @@ version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]]
name = "html2md"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4"
dependencies = [
"html5ever 0.27.0",
"jni 0.19.0",
"lazy_static",
"markup5ever_rcdom",
"percent-encoding",
"regex",
]
[[package]]
name = "html5ever"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
dependencies = [
"log",
"mac",
"markup5ever 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "html5ever"
version = "0.35.0"
@@ -2416,7 +2444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever",
"markup5ever 0.35.0",
"match_token",
]
@@ -2960,6 +2988,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
dependencies = [
"cesu8",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -3318,6 +3360,20 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
dependencies = [
"log",
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "markup5ever"
version = "0.35.0"
@@ -3329,6 +3385,18 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "markup5ever_rcdom"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18"
dependencies = [
"html5ever 0.27.0",
"markup5ever 0.12.1",
"tendril",
"xml5ever",
]
[[package]]
name = "match_token"
version = "0.35.0"
@@ -4270,7 +4338,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"jni 0.21.1",
"ndk",
"ndk-context",
"num-derive",
@@ -4339,6 +4407,7 @@ dependencies = [
"hmac 0.12.1",
"hostname",
"hound",
"html2md",
"image",
"landlock",
"lettre",
@@ -5631,7 +5700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6dcd6e9823e177d15460d3cd3a413f38a2beea381f26aca1001c05cd6954ff"
dependencies = [
"as_variant",
"html5ever",
"html5ever 0.35.0",
"tracing",
"wildmatch",
]
@@ -5773,7 +5842,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys 0.8.7",
"jni",
"jni 0.21.1",
"log",
"once_cell",
"rustls",
@@ -8488,6 +8557,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xml5ever"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69"
dependencies = [
"log",
"mac",
"markup5ever 0.12.1",
]
[[package]]
name = "xxhash-rust"
version = "0.8.15"
+7
View File
@@ -16,6 +16,13 @@ crate-type = ["rlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Used by composio post-processing to convert Gmail HTML bodies to
# markdown. `html2md` is the canonical pure-Rust crate (no system deps);
# `htmd` (>0.5) is more actively maintained but our usage is one
# function call (`parse_html`) and the migration cost outweighs the
# upside today. Re-evaluate if html2md goes unmaintained or we need
# GFM/table fidelity beyond what html2md emits.
html2md = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] }
tokio = { version = "1", features = ["full", "sync"] }
once_cell = "1.19"
@@ -16,8 +16,18 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { authorize, deleteConnection, listConnections } from '../../lib/composio/composioApi';
import { type ComposioConnection, deriveComposioState } from '../../lib/composio/types';
import {
authorize,
deleteConnection,
getUserScopes,
listConnections,
setUserScopes,
} from '../../lib/composio/composioApi';
import {
type ComposioConnection,
type ComposioUserScopePref,
deriveComposioState,
} from '../../lib/composio/types';
import { openUrl } from '../../utils/openUrl';
import type { ComposioToolkitMeta } from './toolkitMeta';
@@ -58,6 +68,16 @@ export default function ComposioConnectModal({
connection
);
// ── Scope preferences (read/write/admin) ────────────────────────
// The pref gates which curated Composio actions the agent may call.
// We load it lazily once the toolkit is connected, so the toggles in
// the success view always reflect what the core actually has stored.
const [scopes, setScopes] = useState<ComposioUserScopePref | null>(null);
const [scopeError, setScopeError] = useState<string | null>(null);
// Per-key in-flight flag so spamming a single toggle disables only
// that row while the RPC round-trips.
const [savingScope, setSavingScope] = useState<keyof ComposioUserScopePref | null>(null);
// Escape to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@@ -175,6 +195,78 @@ export default function ComposioConnectModal({
}
}, [startPolling, toolkit.slug]);
// Fetch the stored scope pref whenever the modal lands in the
// 'connected' phase. Re-fetching each time we transition (rather
// than once on mount) keeps the toggles correct after a fresh OAuth
// handoff completes inside this modal.
useEffect(() => {
if (phase !== 'connected') return;
let cancelled = false;
void (async () => {
try {
const pref = await getUserScopes(toolkit.slug);
if (!cancelled) setScopes(pref);
} catch (err) {
if (!cancelled) {
const msg = err instanceof Error ? err.message : String(err);
setScopeError(`Couldn't load scope preferences: ${msg}`);
}
}
})();
return () => {
cancelled = true;
};
}, [phase, toolkit.slug]);
const handleToggleScope = useCallback(
async (key: keyof ComposioUserScopePref) => {
if (!scopes || savingScope) {
console.debug(
'[composio][scopes] toggle ignored toolkit=%s key=%s reason=%s',
toolkit.slug,
key,
!scopes ? 'pref-not-loaded' : 'another-save-in-flight'
);
return;
}
const optimistic: ComposioUserScopePref = { ...scopes, [key]: !scopes[key] };
console.debug(
'[composio][scopes] toggle toolkit=%s key=%s old=%s new=%s',
toolkit.slug,
key,
scopes[key],
optimistic[key]
);
setScopes(optimistic);
setSavingScope(key);
setScopeError(null);
try {
const persisted = await setUserScopes(toolkit.slug, optimistic);
console.debug(
'[composio][scopes] toggle persisted toolkit=%s key=%s pref=%o',
toolkit.slug,
key,
persisted
);
setScopes(persisted);
} catch (err) {
// Roll back on failure so the toggle reflects reality.
const msg = err instanceof Error ? err.message : String(err);
console.error(
'[composio][scopes] toggle failed toolkit=%s key=%s error=%o',
toolkit.slug,
key,
err
);
setScopes(scopes);
setScopeError(`Couldn't save ${key} scope: ${msg}`);
} finally {
setSavingScope(null);
}
},
[savingScope, scopes, toolkit.slug]
);
const handleDisconnect = useCallback(async () => {
if (!activeConnection) return;
setPhase('disconnecting');
@@ -299,6 +391,12 @@ export default function ComposioConnectModal({
id: {activeConnection.id}
</p>
)}
<ScopeToggles
scopes={scopes}
savingScope={savingScope}
onToggle={handleToggleScope}
error={scopeError}
/>
<button
type="button"
onClick={() => void handleDisconnect()}
@@ -333,3 +431,80 @@ export default function ComposioConnectModal({
return createPortal(modalContent, document.body);
}
// ── Scope toggles ───────────────────────────────────────────────────
const SCOPE_ROWS: Array<{ key: keyof ComposioUserScopePref; label: string; hint: string }> = [
{
key: 'read',
label: 'Read',
hint: 'Allow listing, fetching, searching (e.g. read emails / pages).',
},
{
key: 'write',
label: 'Write',
hint: 'Allow sending, creating, updating (e.g. send emails, create pages).',
},
{
key: 'admin',
label: 'Admin',
hint: 'Allow destructive or permission-changing actions (delete, share, etc.).',
},
];
interface ScopeTogglesProps {
scopes: ComposioUserScopePref | null;
savingScope: keyof ComposioUserScopePref | null;
onToggle: (key: keyof ComposioUserScopePref) => void;
error: string | null;
}
function ScopeToggles({ scopes, savingScope, onToggle, error }: ScopeTogglesProps) {
// Render skeleton placeholders while we wait on the initial load so
// the modal layout doesn't jump when the pref arrives.
const loading = scopes === null;
return (
<div className="border-t border-stone-100 pt-3 mt-1 space-y-2">
<div className="flex items-baseline justify-between">
<h3 className="text-xs font-semibold text-stone-700 uppercase tracking-wide">
Agent permissions
</h3>
<p className="text-[10px] text-stone-400">Read+Write enabled by default</p>
</div>
<ul className="space-y-1.5">
{SCOPE_ROWS.map(row => {
const enabled = scopes?.[row.key] ?? false;
const isSaving = savingScope === row.key;
return (
<li
key={row.key}
className="flex items-start justify-between gap-3 rounded-lg px-2 py-1.5 hover:bg-stone-50">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-stone-900">{row.label}</span>
<p className="text-[11px] text-stone-400 leading-snug">{row.hint}</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={`${enabled ? 'Disable' : 'Enable'} ${row.label} scope`}
disabled={loading || savingScope !== null}
onClick={() => onToggle(row.key)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 ${
enabled ? 'bg-primary-500' : 'bg-stone-300'
}`}>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
enabled ? 'translate-x-5' : 'translate-x-0.5'
} ${isSaving ? 'animate-pulse' : ''}`}
/>
</button>
</li>
);
})}
</ul>
{error && <p className="text-[11px] text-coral-600">{error}</p>}
</div>
);
}
+48
View File
@@ -18,6 +18,7 @@ import type {
ComposioExecuteResponse,
ComposioToolkitsResponse,
ComposioToolsResponse,
ComposioUserScopePref,
} from './types';
/**
@@ -89,6 +90,53 @@ export async function deleteConnection(connectionId: string): Promise<ComposioDe
return unwrapCliEnvelope<ComposioDeleteResponse>(raw);
}
/**
* Read the per-toolkit user scope preference (read/write/admin) used
* to gate `composio_execute`. Returns the default
* `{ read: true, write: true, admin: false }` when nothing is stored.
*/
export async function getUserScopes(toolkit: string): Promise<ComposioUserScopePref> {
console.debug('[composio][scopes] → openhuman.composio_get_user_scopes toolkit=%s', toolkit);
const raw = await callCoreRpc<unknown>({
method: 'openhuman.composio_get_user_scopes',
params: { toolkit },
});
const pref = unwrapCliEnvelope<ComposioUserScopePref>(raw);
console.debug(
'[composio][scopes] ← openhuman.composio_get_user_scopes toolkit=%s pref=%o',
toolkit,
pref
);
return pref;
}
/**
* Persist a per-toolkit user scope preference. The agent will only be
* able to invoke composio actions whose classified scope is enabled
* here.
*/
export async function setUserScopes(
toolkit: string,
pref: ComposioUserScopePref
): Promise<ComposioUserScopePref> {
console.debug(
'[composio][scopes] → openhuman.composio_set_user_scopes toolkit=%s pref=%o',
toolkit,
pref
);
const raw = await callCoreRpc<unknown>({
method: 'openhuman.composio_set_user_scopes',
params: { toolkit, ...pref },
});
const persisted = unwrapCliEnvelope<ComposioUserScopePref>(raw);
console.debug(
'[composio][scopes] ← openhuman.composio_set_user_scopes toolkit=%s persisted=%o',
toolkit,
persisted
);
return persisted;
}
/**
* Execute a Composio action slug (e.g. `GMAIL_SEND_EMAIL`). The core
* charges the caller, tracks usage, and publishes a
+10
View File
@@ -56,6 +56,16 @@ export interface ComposioExecuteResponse {
costUsd: number;
}
/**
* Per-toolkit scope preference stored in the core's KV. Default is
* `{ read: true, write: true, admin: false }`.
*/
export interface ComposioUserScopePref {
read: boolean;
write: boolean;
admin: boolean;
}
// ── UI helpers ────────────────────────────────────────────────────
/**
@@ -13,4 +13,4 @@ omit_skills_catalog = true
hint = "agentic"
[tools]
named = ["composio_list_tools", "file_read"]
named = ["composio_list_tools", "file_read", "composio_execute"]
+11 -65
View File
@@ -40,7 +40,6 @@ use async_trait::async_trait;
use futures::stream::StreamExt;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, Instant};
use thiserror::Error;
@@ -1731,7 +1730,16 @@ fn top_k_for_toolkit(toolkit: &str) -> usize {
/// correctly while staying within budget. If the model needs deeper
/// schema detail it can surface the error and the orchestrator will
/// clarify on the next turn.
pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String {
pub(crate) fn build_text_mode_tool_instructions(_specs: &[ToolSpec]) -> String {
// The tool catalog is already rendered in the prompt's `## Tools`
// section (see `prompts::ToolsSection::build`) with full
// `Call as: NAME[arg|arg]` signatures. We previously also emitted
// an `### Available Tools` subsection here with a different
// formatting (`Parameters: name:type, ...`), which doubled the
// tool list bytes for text-mode agents — especially expensive for
// the integrations_agent toolkit-scoped spawns (~50 actions ×
// 2 listings). Keep only the protocol explanation; the tool
// catalog itself comes from the prompt template.
let mut out = String::new();
out.push_str("## Tool Use Protocol\n\n");
out.push_str(
@@ -1740,73 +1748,11 @@ pub(crate) fn build_text_mode_tool_instructions(specs: &[ToolSpec]) -> String {
in the same response if you need to run calls in parallel.\n\n",
);
out.push_str(
"```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n",
"```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n",
);
out.push_str("### Available Tools\n\n");
for spec in specs {
let _ = writeln!(
out,
"- **{}**: {}",
spec.name,
first_line_truncated(&spec.description, 120)
);
let params_line = summarise_parameters(&spec.parameters);
if !params_line.is_empty() {
let _ = writeln!(out, " Parameters: {}", params_line);
}
}
out
}
/// Render a JSON-schema `parameters` object as a single-line,
/// ultra-compact parameter list — `*name: type, optName: type` for each
/// top-level property (leading `*` marks required). Deeply nested
/// shapes, enums, descriptions, and examples are all dropped.
///
/// Kept intentionally terse: Composio action schemas routinely contain
/// per-parameter descriptions several hundred tokens long, so even a
/// "short description" per param balloons to tens of thousands of
/// tokens across a 27-tool integrations_agent toolkit and pushes the prompt
/// past the 196 607-token context window. The model can infer usage
/// from the parameter names + the tool's overall description; any
/// validation mismatch surfaces at call time and the orchestrator can
/// course-correct on the next turn.
fn summarise_parameters(params: &serde_json::Value) -> String {
let Some(props) = params.get("properties").and_then(|v| v.as_object()) else {
return String::new();
};
let required: std::collections::HashSet<&str> = params
.get("required")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut parts: Vec<String> = Vec::with_capacity(props.len());
for (name, schema) in props {
let ty = schema.get("type").and_then(|v| v.as_str()).unwrap_or("any");
let marker = if required.contains(name.as_str()) {
"*"
} else {
""
};
parts.push(format!("{marker}{name}:{ty}"));
}
parts.join(", ")
}
/// Return the first line of `s`, trimmed and truncated to `max_chars`
/// with a trailing ellipsis when it overflows. Used to keep
/// tool/parameter descriptions on a single grep-friendly line.
fn first_line_truncated(s: &str, max_chars: usize) -> String {
let first = s.lines().next().unwrap_or("").trim();
if first.chars().count() <= max_chars {
first.to_string()
} else {
let truncated: String = first.chars().take(max_chars).collect();
format!("{truncated}")
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool filtering
// ─────────────────────────────────────────────────────────────────────────────
+46 -30
View File
@@ -587,37 +587,49 @@ async fn fetch_connected_integrations_uncached(
// Build one entry per allowlisted toolkit. Connected entries
// carry their action catalogue; not-connected entries carry an
// empty `tools` vec.
let mut integrations: Vec<ConnectedIntegration> = unique_toolkits
.iter()
.map(|slug| {
let connected = connected_slugs.contains(slug);
// Anchor the prefix with an underscore so slugs that share
// a text prefix (e.g. `git` vs `github`) don't false-match
// each other's actions. `GMAIL_SEND_EMAIL` matches `gmail_`,
// not just `gmail`, so siblings stay in their own buckets.
let action_prefix = format!("{}_", slug.to_uppercase());
let tools: Vec<ConnectedIntegrationTool> = if connected {
tools_by_toolkit
.iter()
.filter(|t| t.function.name.starts_with(&action_prefix))
.map(|t| ConnectedIntegrationTool {
name: t.function.name.clone(),
description: t.function.description.clone().unwrap_or_default(),
parameters: t.function.parameters.clone(),
})
.collect()
} else {
Vec::new()
};
let mut integrations: Vec<ConnectedIntegration> = Vec::with_capacity(unique_toolkits.len());
for slug in &unique_toolkits {
let connected = connected_slugs.contains(slug);
// Anchor the prefix with an underscore so slugs that share
// a text prefix (e.g. `git` vs `github`) don't false-match
// each other's actions. `GMAIL_SEND_EMAIL` matches `gmail_`,
// not just `gmail`, so siblings stay in their own buckets.
let action_prefix = format!("{}_", slug.to_uppercase());
let tools: Vec<ConnectedIntegrationTool> = if connected {
// Apply the same curated-whitelist + user-scope filter the
// meta-tool layer uses, so the integrations_agent prompt
// only advertises actions the agent is actually allowed to
// call. One pref load per toolkit (not per action).
let pref = super::providers::load_user_scope_or_default(slug).await;
let filtered: Vec<&super::types::ComposioToolSchema> = tools_by_toolkit
.iter()
.filter(|t| t.function.name.starts_with(&action_prefix))
.filter(|t| super::providers::is_action_visible_with_pref(&t.function.name, &pref))
.collect();
tracing::debug!(
toolkit = %slug,
kept = filtered.len(),
"[composio][scopes] integrations prompt action set"
);
filtered
.into_iter()
.map(|t| ConnectedIntegrationTool {
name: t.function.name.clone(),
description: t.function.description.clone().unwrap_or_default(),
parameters: t.function.parameters.clone(),
})
.collect()
} else {
Vec::new()
};
ConnectedIntegration {
toolkit: slug.clone(),
description: toolkit_description(slug).to_string(),
tools,
connected,
}
})
.collect();
integrations.push(ConnectedIntegration {
toolkit: slug.clone(),
description: toolkit_description(slug).to_string(),
tools,
connected,
});
}
integrations.sort_by(|a, b| a.toolkit.cmp(&b.toolkit));
@@ -671,10 +683,14 @@ pub async fn fetch_toolkit_actions(
.await
.map_err(|e| anyhow::anyhow!("list_tools failed for toolkit `{toolkit_slug}`: {e}"))?;
let action_prefix = format!("{}_", toolkit_slug.to_uppercase());
// Apply curated whitelist + user scope so spawn-time tool
// discovery agrees with the bulk path and the meta-tool layer.
let pref = super::providers::load_user_scope_or_default(toolkit_slug).await;
let actions: Vec<ConnectedIntegrationTool> = resp
.tools
.into_iter()
.filter(|t| t.function.name.starts_with(&action_prefix))
.filter(|t| super::providers::is_action_visible_with_pref(&t.function.name, &pref))
.map(|t| ConnectedIntegrationTool {
name: t.function.name,
description: t.function.description.unwrap_or_default(),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
//! GitHub Composio toolkit — curated tool catalog only.
//!
//! There is no native [`super::ComposioProvider`] implementation for
//! GitHub yet (no profile fetch / sync). The curated catalog here is
//! still consulted by [`super::catalog_for_toolkit`] so the meta-tool
//! layer applies the same whitelist + scope filtering it does for
//! Gmail and Notion.
pub mod tools;
pub use tools::GITHUB_CURATED;
@@ -0,0 +1,188 @@
//! Curated catalog of GitHub Composio actions exposed to the agent.
//!
//! Composio publishes hundreds of GitHub actions; this hand-tuned slice
//! covers the day-to-day operations an AI assistant actually performs
//! (browsing repos, reading/writing issues + PRs, code search, basic
//! workflow control) and hides the long tail of admin endpoints.
use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
pub const GITHUB_CURATED: &[CuratedTool] = &[
// ── Read: user / repos ──────────────────────────────────────────
CuratedTool {
slug: "GITHUB_GET_AUTHENTICATED_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_LIST_REPOSITORIES_FOR_AUTHENTICATED_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_GET_A_REPOSITORY",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_LIST_REPOSITORY_COLLABORATORS",
scope: ToolScope::Read,
},
// ── Read: search ────────────────────────────────────────────────
CuratedTool {
slug: "GITHUB_SEARCH_REPOSITORIES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_SEARCH_CODE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_SEARCH_ISSUES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_SEARCH_USERS",
scope: ToolScope::Read,
},
// ── Read: issues ────────────────────────────────────────────────
CuratedTool {
slug: "GITHUB_LIST_REPOSITORY_ISSUES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_GET_AN_ISSUE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_LIST_ISSUE_COMMENTS",
scope: ToolScope::Read,
},
// ── Read: pull requests ─────────────────────────────────────────
CuratedTool {
slug: "GITHUB_LIST_PULL_REQUESTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_GET_A_PULL_REQUEST",
scope: ToolScope::Read,
},
// CuratedTool { slug: "GITHUB_CHECK_IF_PULL_REQUEST_HAS_BEEN_MERGED", scope: ToolScope::Read },
// ── Read: branches / commits ────────────────────────────────────
CuratedTool {
slug: "GITHUB_LIST_BRANCHES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_GET_A_BRANCH",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_LIST_COMMITS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GITHUB_GET_A_COMMIT",
scope: ToolScope::Read,
},
// CuratedTool { slug: "GITHUB_COMPARE_TWO_COMMITS", scope: ToolScope::Read },
// // ── Read: contents / releases / gists ───────────────────────────
// CuratedTool { slug: "GITHUB_GET_REPOSITORY_CONTENTS", scope: ToolScope::Read },
// CuratedTool { slug: "GITHUB_LIST_RELEASES", scope: ToolScope::Read },
// CuratedTool { slug: "GITHUB_LIST_GISTS", scope: ToolScope::Read },
// // ── Read: workflows ─────────────────────────────────────────────
// CuratedTool { slug: "GITHUB_LIST_WORKFLOWS", scope: ToolScope::Read },
// CuratedTool { slug: "GITHUB_LIST_WORKFLOW_RUNS", scope: ToolScope::Read },
// ── Write: repos / contents ─────────────────────────────────────
CuratedTool {
slug: "GITHUB_CREATE_A_REPOSITORY_FOR_AUTHENTICATED_USER",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_CREATE_OR_UPDATE_FILE_CONTENTS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_CREATE_A_COMMIT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_COMMIT_MULTIPLE_FILES",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_CREATE_A_COMMIT_COMMENT",
scope: ToolScope::Write,
},
// ── Write: issues ───────────────────────────────────────────────
CuratedTool {
slug: "GITHUB_CREATE_AN_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_UPDATE_AN_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_CLOSE_AN_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_CREATE_AN_ISSUE_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_ADD_LABELS_TO_AN_ISSUE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_ADD_ASSIGNEES_TO_AN_ISSUE",
scope: ToolScope::Write,
},
// ── Write: pull requests ────────────────────────────────────────
CuratedTool {
slug: "GITHUB_CREATE_A_PULL_REQUEST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_UPDATE_A_PULL_REQUEST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_MERGE_A_PULL_REQUEST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_CREATE_A_REVIEW_FOR_A_PULL_REQUEST",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GITHUB_CREATE_A_REVIEW_COMMENT_FOR_A_PULL_REQUEST",
scope: ToolScope::Write,
},
// // ── Write: releases / gists / workflows ─────────────────────────
// CuratedTool { slug: "GITHUB_CREATE_A_RELEASE", scope: ToolScope::Write },
CuratedTool {
slug: "GITHUB_CREATE_A_GIST",
scope: ToolScope::Write,
},
// CuratedTool { slug: "GITHUB_CREATE_WORKFLOW_DISPATCH", scope: ToolScope::Write },
// ── Admin: destructive / permission-changing ────────────────────
CuratedTool {
slug: "GITHUB_DELETE_A_REPOSITORY",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GITHUB_DELETE_A_BRANCH",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GITHUB_DELETE_A_FILE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GITHUB_ADD_A_REPOSITORY_COLLABORATOR",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GITHUB_CANCEL_WORKFLOW_RUN",
scope: ToolScope::Admin,
},
];
@@ -2,5 +2,7 @@ mod provider;
mod sync;
#[cfg(test)]
mod tests;
pub mod tools;
pub use provider::GmailProvider;
pub use tools::GMAIL_CURATED;
@@ -25,7 +25,8 @@ use crate::openhuman::composio::providers::sync_state::{
extract_item_id, persist_single_item, SyncState,
};
use crate::openhuman::composio::providers::{
pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason,
pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
SyncReason,
};
const ACTION_GET_PROFILE: &str = "GMAIL_GET_PROFILE";
@@ -79,6 +80,10 @@ impl ComposioProvider for GmailProvider {
"gmail"
}
fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
Some(super::tools::GMAIL_CURATED)
}
fn sync_interval_secs(&self) -> Option<u64> {
Some(15 * 60)
}
@@ -0,0 +1,145 @@
//! Curated catalog of Gmail Composio actions exposed to the agent.
//!
//! Composio publishes 60+ Gmail actions; this hand-tuned slice covers
//! the cases the agent actually plans for (read, compose, manage) and
//! hides the long tail of edge-case admin endpoints.
use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
pub const GMAIL_CURATED: &[CuratedTool] = &[
// ── Read: messages & threads ────────────────────────────────────
CuratedTool {
slug: "GMAIL_FETCH_EMAILS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_LIST_MESSAGES",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_FETCH_MESSAGE_BY_THREAD_ID",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_LIST_THREADS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_GET_ATTACHMENT",
scope: ToolScope::Read,
},
// ── Read: profile & settings ────────────────────────────────────
CuratedTool {
slug: "GMAIL_GET_PROFILE",
scope: ToolScope::Read,
},
// CuratedTool { slug: "GMAIL_GET_LANGUAGE_SETTINGS", scope: ToolScope::Read },
// CuratedTool { slug: "GMAIL_GET_VACATION_SETTINGS", scope: ToolScope::Read },
// CuratedTool { slug: "GMAIL_GET_AUTO_FORWARDING", scope: ToolScope::Read },
// ── Read: contacts & people ─────────────────────────────────────
CuratedTool {
slug: "GMAIL_GET_CONTACTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_GET_PEOPLE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_SEARCH_PEOPLE",
scope: ToolScope::Read,
},
// ── Read: drafts & labels ───────────────────────────────────────
CuratedTool {
slug: "GMAIL_LIST_DRAFTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_GET_DRAFT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_LIST_LABELS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "GMAIL_GET_LABEL",
scope: ToolScope::Read,
},
// ── Write: send & compose ───────────────────────────────────────
CuratedTool {
slug: "GMAIL_SEND_EMAIL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GMAIL_REPLY_TO_THREAD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GMAIL_FORWARD_MESSAGE",
scope: ToolScope::Write,
},
// ── Write: drafts ───────────────────────────────────────────────
CuratedTool {
slug: "GMAIL_CREATE_EMAIL_DRAFT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GMAIL_UPDATE_DRAFT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "GMAIL_SEND_DRAFT",
scope: ToolScope::Write,
},
// ── Write: labels (create/update on user labels) ────────────────
// CuratedTool { slug: "GMAIL_CREATE_LABEL", scope: ToolScope::Write },
// CuratedTool { slug: "GMAIL_UPDATE_LABEL", scope: ToolScope::Write },
// CuratedTool { slug: "GMAIL_PATCH_LABEL", scope: ToolScope::Write },
CuratedTool {
slug: "GMAIL_ADD_LABEL_TO_EMAIL",
scope: ToolScope::Write,
},
// ── Admin: destructive & permission-changing ────────────────────
CuratedTool {
slug: "GMAIL_DELETE_MESSAGE",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GMAIL_BATCH_DELETE_MESSAGES",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GMAIL_MOVE_TO_TRASH",
scope: ToolScope::Admin,
},
// CuratedTool { slug: "GMAIL_UNTRASH_MESSAGE", scope: ToolScope::Admin },
CuratedTool {
slug: "GMAIL_DELETE_THREAD",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GMAIL_MOVE_THREAD_TO_TRASH",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GMAIL_UNTRASH_THREAD",
scope: ToolScope::Admin,
},
// CuratedTool { slug: "GMAIL_MODIFY_THREAD_LABELS", scope: ToolScope::Admin },
// CuratedTool { slug: "GMAIL_BATCH_MODIFY_MESSAGES", scope: ToolScope::Admin },
CuratedTool {
slug: "GMAIL_DELETE_DRAFT",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "GMAIL_DELETE_LABEL",
scope: ToolScope::Admin,
},
// CuratedTool { slug: "GMAIL_PATCH_SEND_AS", scope: ToolScope::Admin },
// CuratedTool { slug: "GMAIL_UPDATE_IMAP_SETTINGS", scope: ToolScope::Admin },
];
+83
View File
@@ -34,22 +34,105 @@
mod descriptions;
pub(crate) mod helpers;
pub mod tool_scope;
mod traits;
mod types;
pub mod user_scopes;
pub mod catalogs;
pub mod github;
pub mod gmail;
pub mod notion;
pub mod post_process;
pub mod profile;
pub mod registry;
pub mod sync_state;
/// Static toolkit → curated catalog map.
///
/// This is consulted by the meta-tool layer alongside any registered
/// provider's [`ComposioProvider::curated_tools`]. It lets toolkits
/// without a full native provider (e.g. `github`, which has no sync
/// logic yet) still benefit from curated whitelisting.
///
/// Lookup key is the lowercased prefix returned by
/// [`toolkit_from_slug`] applied to the action slug — e.g.
/// `GOOGLECALENDAR_CREATE_EVENT` → `"googlecalendar"`. Multi-segment
/// prefixes like `MICROSOFT_TEAMS_*` are matched via their first
/// segment with an extra arm.
/// Synchronous visibility check for a Composio action slug given a
/// pre-loaded user scope preference.
///
/// Returns `true` if the action should appear in the agent's tool
/// surface — i.e. it's in the toolkit's curated whitelist (or the
/// toolkit has no curation) **and** the user's scope pref allows its
/// classification. Falls back to [`classify_unknown`] for un-curated
/// toolkits.
///
/// Use this when the user pref has already been loaded for the
/// toolkit (typical inside a `for slug in toolkits {...}` loop where
/// awaiting once per toolkit is cheaper than once per action).
pub fn is_action_visible_with_pref(slug: &str, pref: &UserScopePref) -> bool {
let Some(toolkit) = toolkit_from_slug(slug) else {
return true;
};
let catalog = get_provider(&toolkit)
.and_then(|p| p.curated_tools())
.or_else(|| catalog_for_toolkit(&toolkit));
match catalog {
Some(catalog) => match find_curated(catalog, slug) {
Some(curated) => pref.allows(curated.scope),
None => false,
},
None => pref.allows(classify_unknown(slug)),
}
}
pub fn catalog_for_toolkit(toolkit: &str) -> Option<&'static [CuratedTool]> {
match toolkit.trim().to_ascii_lowercase().as_str() {
// Native providers
"gmail" => Some(gmail::GMAIL_CURATED),
"notion" => Some(notion::NOTION_CURATED),
"github" => Some(github::GITHUB_CURATED),
// Catalog-only toolkits
"slack" => Some(catalogs::SLACK_CURATED),
"discord" => Some(catalogs::DISCORD_CURATED),
"googlecalendar" | "google_calendar" => Some(catalogs::GOOGLECALENDAR_CURATED),
"googledrive" | "google_drive" => Some(catalogs::GOOGLEDRIVE_CURATED),
"googledocs" | "google_docs" => Some(catalogs::GOOGLEDOCS_CURATED),
"googlesheets" | "google_sheets" => Some(catalogs::GOOGLESHEETS_CURATED),
"outlook" => Some(catalogs::OUTLOOK_CURATED),
// MICROSOFT_TEAMS_* slugs extract to "microsoft" via toolkit_from_slug.
"microsoft" | "microsoft_teams" => Some(catalogs::MICROSOFT_TEAMS_CURATED),
"linear" => Some(catalogs::LINEAR_CURATED),
"jira" => Some(catalogs::JIRA_CURATED),
"trello" => Some(catalogs::TRELLO_CURATED),
"asana" => Some(catalogs::ASANA_CURATED),
"dropbox" => Some(catalogs::DROPBOX_CURATED),
"twitter" => Some(catalogs::TWITTER_CURATED),
"spotify" => Some(catalogs::SPOTIFY_CURATED),
"telegram" => Some(catalogs::TELEGRAM_CURATED),
"whatsapp" => Some(catalogs::WHATSAPP_CURATED),
"shopify" => Some(catalogs::SHOPIFY_CURATED),
"stripe" => Some(catalogs::STRIPE_CURATED),
"hubspot" => Some(catalogs::HUBSPOT_CURATED),
"salesforce" => Some(catalogs::SALESFORCE_CURATED),
"airtable" => Some(catalogs::AIRTABLE_CURATED),
"figma" => Some(catalogs::FIGMA_CURATED),
"youtube" => Some(catalogs::YOUTUBE_CURATED),
_ => None,
}
}
pub use descriptions::toolkit_description;
pub(crate) use helpers::pick_str;
pub use registry::{
all_providers, get_provider, init_default_providers, register_provider, ProviderArc,
};
pub use tool_scope::{classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope};
pub use traits::ComposioProvider;
pub use types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason};
pub use user_scopes::{load_or_default as load_user_scope_or_default, UserScopePref};
#[cfg(test)]
mod tests {
@@ -2,5 +2,7 @@ mod provider;
mod sync;
#[cfg(test)]
mod tests;
pub mod tools;
pub use provider::NotionProvider;
pub use tools::NOTION_CURATED;
@@ -23,7 +23,8 @@ use crate::openhuman::composio::providers::sync_state::{
extract_item_id, persist_single_item, SyncState,
};
use crate::openhuman::composio::providers::{
pick_str, ComposioProvider, ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason,
pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
SyncReason,
};
pub(crate) const ACTION_GET_ABOUT_ME: &str = "NOTION_GET_ABOUT_ME";
@@ -69,6 +70,10 @@ impl ComposioProvider for NotionProvider {
"notion"
}
fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
Some(super::tools::NOTION_CURATED)
}
fn sync_interval_secs(&self) -> Option<u64> {
Some(30 * 60)
}
@@ -0,0 +1,196 @@
//! Curated catalog of Notion Composio actions exposed to the agent.
use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
pub const NOTION_CURATED: &[CuratedTool] = &[
// ── Read: search & fetch ────────────────────────────────────────
CuratedTool {
slug: "NOTION_SEARCH_NOTION_PAGE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_FETCH_DATA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_FETCH_DATABASE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_FETCH_ROW",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_FETCH_BLOCK_METADATA",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_FETCH_BLOCK_CONTENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_FETCH_ALL_BLOCK_CONTENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_FETCH_COMMENTS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_GET_PAGE_MARKDOWN",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_GET_PAGE_PROPERTY_ACTION",
scope: ToolScope::Read,
},
// ── Read: query & retrieve ──────────────────────────────────────
CuratedTool {
slug: "NOTION_QUERY_DATABASE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_QUERY_DATABASE_WITH_FILTER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_QUERY_DATA_SOURCE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_RETRIEVE_PAGE",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_RETRIEVE_COMMENT",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_RETRIEVE_DATABASE_PROPERTY",
scope: ToolScope::Read,
},
// ── Read: profile / users / files ───────────────────────────────
CuratedTool {
slug: "NOTION_LIST_USERS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_GET_ABOUT_USER",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_GET_ABOUT_ME",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_LIST_FILE_UPLOADS",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_RETRIEVE_FILE_UPLOAD",
scope: ToolScope::Read,
},
CuratedTool {
slug: "NOTION_LIST_DATA_SOURCE_TEMPLATES",
scope: ToolScope::Read,
},
// ── Write: create ───────────────────────────────────────────────
CuratedTool {
slug: "NOTION_CREATE_NOTION_PAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_CREATE_DATABASE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_CREATE_COMMENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_CREATE_FILE_UPLOAD",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_SEND_FILE_UPLOAD",
scope: ToolScope::Write,
},
// ── Write: update / append ──────────────────────────────────────
CuratedTool {
slug: "NOTION_UPDATE_PAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_UPDATE_BLOCK",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_UPDATE_ROW_DATABASE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_INSERT_ROW_DATABASE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_INSERT_ROW_FROM_NL",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_REPLACE_PAGE_CONTENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_ADD_PAGE_CONTENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_ADD_MULTIPLE_PAGE_CONTENT",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_APPEND_BLOCK_CHILDREN",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_APPEND_TEXT_BLOCKS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_APPEND_TASK_BLOCKS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_APPEND_CODE_BLOCKS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_APPEND_MEDIA_BLOCKS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_APPEND_LAYOUT_BLOCKS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_APPEND_TABLE_BLOCKS",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_DUPLICATE_PAGE",
scope: ToolScope::Write,
},
CuratedTool {
slug: "NOTION_MOVE_PAGE",
scope: ToolScope::Write,
},
// ── Admin: destructive ──────────────────────────────────────────
CuratedTool {
slug: "NOTION_DELETE_BLOCK",
scope: ToolScope::Admin,
},
CuratedTool {
slug: "NOTION_ARCHIVE_NOTION_PAGE",
scope: ToolScope::Admin,
},
];
@@ -0,0 +1,188 @@
//! Per-toolkit post-processing of Composio action responses.
//!
//! Some upstream services return content in formats that are noisy for
//! the agent's context window (e.g. Gmail's full HTML message body).
//! This module gives each toolkit a chance to rewrite the response
//! before it is handed back to the LLM — for instance converting HTML
//! email bodies to markdown so the model spends fewer tokens parsing
//! presentational markup.
//!
//! The dispatch is intentionally tiny: one function per toolkit that
//! mutates a `serde_json::Value` in place. The Composio backend keeps
//! evolving its response shapes so we walk values defensively rather
//! than hard-coding field paths.
use serde_json::Value;
/// Apply toolkit-specific post-processing to an `composio_execute`
/// response. Mutates `value` in place.
///
/// Calling this with an unknown toolkit slug is a no-op.
pub fn post_process(toolkit: &str, _slug: &str, value: &mut Value) {
let key = toolkit.trim().to_ascii_lowercase();
match key.as_str() {
"gmail" => convert_html_strings(value, "gmail"),
_ => {}
}
}
/// Walk `value` recursively. Any string field whose contents look like
/// HTML is replaced with its markdown rendering.
///
/// We use a substring heuristic instead of a full parse for speed —
/// if the string contains both `<` and one of a few common email
/// tags it's almost certainly HTML. False positives are harmless
/// (the html2md output for a non-HTML string is essentially the
/// stripped text).
fn convert_html_strings(value: &mut Value, toolkit: &str) {
match value {
Value::String(s) => {
if looks_like_html(s) {
let md = html2md::parse_html(s);
tracing::debug!(
toolkit,
before_bytes = s.len(),
after_bytes = md.len(),
"[composio][post-process] html → markdown"
);
*s = md;
}
}
Value::Array(items) => {
for item in items {
convert_html_strings(item, toolkit);
}
}
Value::Object(map) => {
for (_, v) in map.iter_mut() {
convert_html_strings(v, toolkit);
}
}
_ => {}
}
}
/// Heuristic HTML detector. Returns `true` if the string contains an
/// opening `<` followed (anywhere) by one of a handful of common tags
/// found in email bodies. Tuned to avoid matching on stray angle
/// brackets in plain-text quoted replies.
fn looks_like_html(s: &str) -> bool {
if s.len() < 4 || !s.contains('<') {
return false;
}
// Cheap substring scan; case-insensitive via lowercased haystack.
// Bound the work for very large strings — we only need the first
// few KB to make the call. Walk back to a UTF-8 char boundary so we
// never slice in the middle of a multibyte sequence (4096 may land
// mid-codepoint).
let head = if s.len() > 4096 {
let mut end = 4096;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
} else {
s
};
let lower = head.to_ascii_lowercase();
const MARKERS: &[&str] = &[
"<html",
"<body",
"<head>",
"<div",
"<p>",
"<p ",
"<br>",
"<br/",
"<br />",
"<a ",
"<table",
"<span",
"<img ",
"<ul",
"<ol",
"<li>",
"<h1",
"<h2",
"<h3",
"<blockquote",
"<style",
"<meta",
"<!doctype",
];
MARKERS.iter().any(|m| lower.contains(m))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn looks_like_html_detects_common_tags() {
assert!(looks_like_html("<html><body>Hi</body></html>"));
assert!(looks_like_html("<p>Hello <b>world</b></p>"));
assert!(looks_like_html("<DIV>Mixed Case</DIV>"));
assert!(looks_like_html("<a href=\"x\">link</a>"));
}
#[test]
fn looks_like_html_does_not_panic_on_multibyte_at_boundary() {
// Build a string > 4096 bytes whose 4096th byte lands inside a
// 3-byte UTF-8 codepoint. Naive `&s[..4096]` slicing panics here.
let mut s = String::with_capacity(4200);
// 4095 ASCII bytes, then a 3-byte CJK char straddling 4095..4098.
s.push_str(&"a".repeat(4095));
s.push('日');
s.push_str(&"b".repeat(100));
// Should not panic; result doesn't matter, only that it returns.
let _ = looks_like_html(&s);
}
#[test]
fn looks_like_html_rejects_plain_text() {
assert!(!looks_like_html("just a normal email body"));
assert!(!looks_like_html(""));
assert!(!looks_like_html("a < b but no tags"));
assert!(!looks_like_html("<<>>")); // not a real tag
}
#[test]
fn convert_walks_nested_arrays_and_objects() {
let mut v = json!({
"messages": [
{ "id": "m1", "messageText": "<p>hello <b>world</b></p>" },
{ "id": "m2", "messageText": "plain text only" }
],
"nextPageToken": null
});
convert_html_strings(&mut v, "gmail");
let m1 = v["messages"][0]["messageText"].as_str().unwrap();
// html2md emits at least the text content.
assert!(m1.contains("hello"));
assert!(m1.contains("world"));
assert!(!m1.contains("<p>"));
// Plain text untouched.
assert_eq!(v["messages"][1]["messageText"], "plain text only");
}
#[test]
fn post_process_unknown_toolkit_is_noop() {
let mut v = json!({ "body": "<p>hi</p>" });
let original = v.clone();
post_process("notion", "NOTION_FETCH_DATA", &mut v);
assert_eq!(v, original);
}
#[test]
fn post_process_gmail_converts_html_fields() {
let mut v = json!({
"data": { "html": "<h1>Hi</h1><p>body</p>", "subject": "no tags here" }
});
post_process("gmail", "GMAIL_FETCH_EMAILS", &mut v);
let html_field = v["data"]["html"].as_str().unwrap();
assert!(!html_field.contains("<h1>"));
assert!(html_field.contains("Hi"));
assert_eq!(v["data"]["subject"], "no tags here");
}
}
@@ -0,0 +1,185 @@
//! Per-action scope classification (read / write / admin) plus the
//! [`CuratedTool`] catalog type that providers use to whitelist the
//! actions they want surfaced to the agent.
//!
//! Composio publishes 60+ actions per toolkit; most are noise for the
//! agent's planning loop. Each provider exports a hand-curated
//! [`CuratedTool`] slice via [`super::ComposioProvider::curated_tools`]
//! that pares the surface down to a useful subset and tags every action
//! with a [`ToolScope`] so per-user scope preferences can gate execution.
use serde::{Deserialize, Serialize};
/// Classification of how invasive an action is.
///
/// Used both to filter the agent's visible tool list and to enforce
/// per-user scope preferences at execution time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolScope {
/// Pure reads — `GET` / `FETCH` / `LIST` / `SEARCH` / `GET_PROFILE`.
Read,
/// Side-effectful actions that create or mutate user data —
/// `SEND` / `CREATE` / `UPDATE` / `REPLY` / `APPEND`.
Write,
/// Destructive or permission-changing actions — `DELETE` / `TRASH` /
/// `REMOVE` / `MODIFY_LABELS` / `SHARE`.
Admin,
}
impl ToolScope {
pub fn as_str(self) -> &'static str {
match self {
ToolScope::Read => "read",
ToolScope::Write => "write",
ToolScope::Admin => "admin",
}
}
}
/// One curated entry in a provider's tool catalog.
///
/// `slug` is the Composio action slug as returned by `composio_list_tools`
/// (e.g. `"GMAIL_SEND_EMAIL"`). `scope` controls whether the action is
/// gated by the user's read / write / admin preference.
#[derive(Debug, Clone, Copy)]
pub struct CuratedTool {
pub slug: &'static str,
pub scope: ToolScope,
}
/// Heuristic fallback when we need to gate a tool that isn't in any
/// provider's curated list. Prefer the curated classification when
/// available; only call this when [`super::ComposioProvider::curated_tools`]
/// returned `None` or didn't include the slug.
pub fn classify_unknown(slug: &str) -> ToolScope {
let upper = slug.to_ascii_uppercase();
// Admin verbs are checked first so e.g. `MODIFY_LABELS` doesn't slip
// into the Write bucket on the `UPDATE`-substring rule.
const ADMIN: &[&str] = &[
"DELETE",
"TRASH",
"REMOVE",
"MODIFY_LABELS",
"SHARE",
"REVOKE",
"DESTROY",
];
const WRITE: &[&str] = &[
"SEND", "CREATE", "UPDATE", "REPLY", "APPEND", "INSERT", "ADD", "POST", "PATCH", "WRITE",
"DRAFT",
];
if ADMIN.iter().any(|kw| upper.contains(kw)) {
return ToolScope::Admin;
}
if WRITE.iter().any(|kw| upper.contains(kw)) {
return ToolScope::Write;
}
ToolScope::Read
}
/// Look up a slug inside a curated catalog.
pub fn find_curated<'a>(catalog: &'a [CuratedTool], slug: &str) -> Option<&'a CuratedTool> {
catalog.iter().find(|t| t.slug.eq_ignore_ascii_case(slug))
}
/// Extract the toolkit slug from a Composio action slug.
///
/// All Composio action slugs follow the convention `<TOOLKIT>_<VERB>_…`
/// (e.g. `GMAIL_SEND_EMAIL` → `gmail`). Returns the lowercased prefix
/// before the first underscore, or `None` if the slug has no underscore.
///
/// **Assumption:** toolkit identifiers themselves do not contain
/// underscores. Composio honours this for every action we curate today
/// (`gmail`, `notion`, `googlecalendar`, …). The one historical
/// exception — `MICROSOFT_TEAMS_*` — extracts to `"microsoft"`, and
/// [`super::catalog_for_toolkit`] handles the alias by mapping both
/// `"microsoft"` and `"microsoft_teams"` to the same catalog.
///
/// If a future toolkit ships with a multi-word slug containing an
/// underscore in the *toolkit* portion (e.g. a hypothetical
/// `FOO_BAR_LIST_ITEMS` whose toolkit is `foo_bar`), this naive split
/// must be revised — either by consulting a known-toolkits map or by
/// taking the longest-matching prefix from the registered catalogs.
pub fn toolkit_from_slug(slug: &str) -> Option<String> {
let trimmed = slug.trim();
if trimmed.is_empty() {
return None;
}
let prefix = trimmed.split('_').next()?;
if prefix.is_empty() {
None
} else {
Some(prefix.to_ascii_lowercase())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_unknown_picks_admin_for_destructive_verbs() {
assert_eq!(classify_unknown("GMAIL_DELETE_EMAIL"), ToolScope::Admin);
assert_eq!(classify_unknown("GMAIL_TRASH_EMAIL"), ToolScope::Admin);
assert_eq!(classify_unknown("GMAIL_MODIFY_LABELS"), ToolScope::Admin);
}
#[test]
fn classify_unknown_picks_write_for_mutating_verbs() {
assert_eq!(classify_unknown("GMAIL_SEND_EMAIL"), ToolScope::Write);
assert_eq!(classify_unknown("NOTION_CREATE_PAGE"), ToolScope::Write);
assert_eq!(classify_unknown("NOTION_UPDATE_PAGE"), ToolScope::Write);
}
#[test]
fn classify_unknown_defaults_to_read() {
assert_eq!(classify_unknown("GMAIL_FETCH_EMAILS"), ToolScope::Read);
assert_eq!(classify_unknown("NOTION_SEARCH"), ToolScope::Read);
assert_eq!(classify_unknown("GMAIL_GET_PROFILE"), ToolScope::Read);
}
#[test]
fn classify_unknown_admin_takes_precedence_over_write() {
// MODIFY_LABELS contains no write verb but DELETE_DRAFT does — make
// sure the admin check wins.
assert_eq!(classify_unknown("GMAIL_DELETE_DRAFT"), ToolScope::Admin);
}
#[test]
fn toolkit_from_slug_extracts_lowercase_prefix() {
assert_eq!(
toolkit_from_slug("GMAIL_SEND_EMAIL"),
Some("gmail".to_string())
);
assert_eq!(
toolkit_from_slug("NOTION_FETCH_DATA"),
Some("notion".to_string())
);
assert_eq!(toolkit_from_slug(""), None);
assert_eq!(
toolkit_from_slug("noUnderscore"),
Some("nounderscore".into())
);
}
#[test]
fn find_curated_is_case_insensitive() {
let catalog = &[CuratedTool {
slug: "GMAIL_SEND_EMAIL",
scope: ToolScope::Write,
}];
assert!(find_curated(catalog, "gmail_send_email").is_some());
assert!(find_curated(catalog, "GMAIL_SEND_EMAIL").is_some());
assert!(find_curated(catalog, "GMAIL_DELETE_EMAIL").is_none());
}
#[test]
fn tool_scope_serializes_lowercase() {
assert_eq!(serde_json::to_string(&ToolScope::Read).unwrap(), "\"read\"");
assert_eq!(
serde_json::to_string(&ToolScope::Admin).unwrap(),
"\"admin\""
);
}
}
@@ -2,6 +2,7 @@
use async_trait::async_trait;
use super::tool_scope::CuratedTool;
use super::types::{ProviderContext, ProviderUserProfile, SyncOutcome, SyncReason};
/// Native provider implementation for a specific Composio toolkit.
@@ -22,6 +23,22 @@ pub trait ComposioProvider: Send + Sync {
Some(15 * 60)
}
/// Curated whitelist of Composio actions this provider considers
/// useful for the agent, classified by [`super::tool_scope::ToolScope`].
///
/// When `Some(&[...])`, the meta-tool layer hides every action not
/// in this list from `composio_list_tools` and rejects execution of
/// any slug not in this list (or whose scope is disabled in the
/// user's pref).
///
/// Default: `None` — toolkits without a curated catalog (e.g.
/// integrations not yet hand-tuned) pass through all actions and
/// rely on the [`super::tool_scope::classify_unknown`] heuristic for
/// scope gating.
fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
None
}
/// Fetch a normalized user profile for the current connection in
/// `ctx`. Most providers implement this by calling a provider
/// "get profile / about me" action via [`super::super::ops::composio_execute`].
@@ -0,0 +1,201 @@
//! Per-user, per-toolkit scope preferences.
//!
//! For each Composio toolkit a user has connected (or could connect),
//! we store a [`UserScopePref`] that records whether the agent is
//! allowed to call **read**, **write**, and / or **admin**-classified
//! actions for that toolkit. Defaults are `read=true, write=true,
//! admin=false` — the agent can use the integration productively out of
//! the box, but destructive / permission-changing actions require
//! explicit opt-in.
//!
//! Storage uses the same KV surface as [`super::sync_state`]
//! (`MemoryClient::kv_get` / `kv_set`) under a dedicated namespace so
//! prefs survive process restarts without any extra file management.
use serde::{Deserialize, Serialize};
use crate::openhuman::memory::MemoryClientRef;
use super::tool_scope::ToolScope;
/// KV namespace for scope prefs. Separate from `composio-sync-state` so
/// the two never collide.
const KV_NAMESPACE: &str = "composio-user-scopes";
/// Per-toolkit scope preference.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserScopePref {
#[serde(default = "default_true")]
pub read: bool,
#[serde(default = "default_true")]
pub write: bool,
#[serde(default)]
pub admin: bool,
}
fn default_true() -> bool {
true
}
impl Default for UserScopePref {
fn default() -> Self {
Self {
read: true,
write: true,
admin: false,
}
}
}
impl UserScopePref {
/// Returns `true` if the given scope is enabled in this preference.
pub fn allows(&self, scope: ToolScope) -> bool {
match scope {
ToolScope::Read => self.read,
ToolScope::Write => self.write,
ToolScope::Admin => self.admin,
}
}
}
fn kv_key(toolkit: &str) -> String {
toolkit.trim().to_ascii_lowercase()
}
/// Load the scope pref for `toolkit`. Returns the default
/// (`read+write`, no `admin`) when nothing is stored or when the KV
/// store can't be reached — the agent should always be able to use
/// connected integrations productively, even if pref storage is
/// temporarily unavailable.
pub async fn load(memory: &MemoryClientRef, toolkit: &str) -> UserScopePref {
let key = kv_key(toolkit);
if key.is_empty() {
return UserScopePref::default();
}
match memory.kv_get(Some(KV_NAMESPACE), &key).await {
Ok(Some(value)) => match serde_json::from_value::<UserScopePref>(value) {
Ok(pref) => {
tracing::debug!(
toolkit = %key,
read = pref.read,
write = pref.write,
admin = pref.admin,
"[composio][scopes] pref loaded"
);
pref
}
Err(e) => {
tracing::warn!(
toolkit = %key,
error = %e,
"[composio][scopes] pref deserialize failed, falling back to default"
);
UserScopePref::default()
}
},
Ok(None) => {
tracing::debug!(
toolkit = %key,
"[composio][scopes] no pref stored, using default (read+write)"
);
UserScopePref::default()
}
Err(e) => {
tracing::warn!(
toolkit = %key,
error = %e,
"[composio][scopes] kv_get failed, falling back to default"
);
UserScopePref::default()
}
}
}
/// Persist a scope pref for `toolkit`.
pub async fn save(
memory: &MemoryClientRef,
toolkit: &str,
pref: UserScopePref,
) -> Result<(), String> {
let key = kv_key(toolkit);
if key.is_empty() {
return Err("user_scopes: toolkit must not be empty".to_string());
}
let value = serde_json::to_value(pref)
.map_err(|e| format!("[composio][scopes] serialize failed: {e}"))?;
memory.kv_set(Some(KV_NAMESPACE), &key, &value).await?;
tracing::info!(
toolkit = %key,
read = pref.read,
write = pref.write,
admin = pref.admin,
"[composio][scopes] pref saved"
);
Ok(())
}
/// Best-effort load that resolves the active memory client itself. Used
/// from the meta-tool layer where we don't have a `MemoryClientRef` in
/// scope. Falls back to the default pref when memory isn't initialised.
pub async fn load_or_default(toolkit: &str) -> UserScopePref {
match crate::openhuman::memory::global::client_if_ready() {
Some(client) => load(&client, toolkit).await,
None => {
// Match the normalized key form `load()` logs so traces
// grouped by `key` correlate across both code paths.
let key = kv_key(toolkit);
tracing::debug!(
toolkit = %toolkit,
key = %key,
"[composio][scopes] memory not ready, using default pref"
);
UserScopePref::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_read_write_no_admin() {
let p = UserScopePref::default();
assert!(p.read);
assert!(p.write);
assert!(!p.admin);
}
#[test]
fn allows_matches_scope() {
let p = UserScopePref {
read: true,
write: false,
admin: false,
};
assert!(p.allows(ToolScope::Read));
assert!(!p.allows(ToolScope::Write));
assert!(!p.allows(ToolScope::Admin));
}
#[test]
fn round_trip_serde() {
let p = UserScopePref {
read: true,
write: true,
admin: true,
};
let v = serde_json::to_value(p).unwrap();
let back: UserScopePref = serde_json::from_value(v).unwrap();
assert_eq!(p, back);
}
#[test]
fn missing_fields_default_to_true_for_read_write() {
// Forward-compat: if we ever drop a field, existing stored
// documents still deserialize sensibly.
let v = serde_json::json!({});
let p: UserScopePref = serde_json::from_value(v).unwrap();
assert_eq!(p, UserScopePref::default());
}
}
+159
View File
@@ -51,6 +51,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("get_user_profile"),
schemas("sync"),
schemas("list_trigger_history"),
schemas("get_user_scopes"),
schemas("set_user_scopes"),
]
}
@@ -100,6 +102,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("list_trigger_history"),
handler: handle_list_trigger_history,
},
RegisteredController {
schema: schemas("get_user_scopes"),
handler: handle_get_user_scopes,
},
RegisteredController {
schema: schemas("set_user_scopes"),
handler: handle_set_user_scopes,
},
]
}
@@ -334,6 +344,65 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"get_user_scopes" => ControllerSchema {
namespace: "composio",
function: "get_user_scopes",
description:
"Read the per-toolkit user scope preference (read/write/admin) used to gate \
composio_execute. Defaults to {read:true, write:true, admin:false} when no \
pref is stored.",
inputs: vec![FieldSchema {
name: "toolkit",
ty: TypeSchema::String,
comment: "Toolkit slug, e.g. 'gmail' or 'notion'.",
required: true,
}],
outputs: vec![FieldSchema {
name: "pref",
ty: TypeSchema::Json,
comment: "Scope pref: { read: bool, write: bool, admin: bool }.",
required: true,
}],
},
"set_user_scopes" => ControllerSchema {
namespace: "composio",
function: "set_user_scopes",
description:
"Persist a per-toolkit user scope preference. The agent will only be able to \
invoke composio actions whose classified scope is enabled here.",
inputs: vec![
FieldSchema {
name: "toolkit",
ty: TypeSchema::String,
comment: "Toolkit slug, e.g. 'gmail' or 'notion'.",
required: true,
},
FieldSchema {
name: "read",
ty: TypeSchema::Bool,
comment: "Allow read-classified actions (GET / FETCH / LIST / SEARCH).",
required: true,
},
FieldSchema {
name: "write",
ty: TypeSchema::Bool,
comment: "Allow write-classified actions (SEND / CREATE / UPDATE).",
required: true,
},
FieldSchema {
name: "admin",
ty: TypeSchema::Bool,
comment: "Allow admin-classified actions (DELETE / TRASH / SHARE).",
required: true,
},
],
outputs: vec![FieldSchema {
name: "pref",
ty: TypeSchema::Json,
comment: "Stored scope pref: { read, write, admin }.",
required: true,
}],
},
_other => ControllerSchema {
namespace: "composio",
function: "unknown",
@@ -459,6 +528,94 @@ fn handle_sync(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_get_user_scopes(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let toolkit = match read_required_non_empty(&params, "toolkit") {
Ok(v) => v,
Err(e) => {
tracing::warn!(
method = "composio.get_user_scopes",
error = %e,
"[composio:scopes] missing required `toolkit` param"
);
return Err(e);
}
};
tracing::debug!(
method = "composio.get_user_scopes",
toolkit = %toolkit,
"[composio:scopes] handler entry"
);
let pref = super::providers::user_scopes::load_or_default(&toolkit).await;
tracing::debug!(
method = "composio.get_user_scopes",
toolkit = %toolkit,
read = pref.read,
write = pref.write,
admin = pref.admin,
"[composio:scopes] handler exit"
);
to_json(crate::rpc::RpcOutcome::new(pref, vec![]))
})
}
fn handle_set_user_scopes(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let toolkit = match read_required_non_empty(&params, "toolkit") {
Ok(v) => v,
Err(e) => {
tracing::warn!(
method = "composio.set_user_scopes",
error = %e,
"[composio:scopes] missing required `toolkit` param"
);
return Err(e);
}
};
let read: bool = read_required(&params, "read")?;
let write: bool = read_required(&params, "write")?;
let admin: bool = read_required(&params, "admin")?;
let pref = super::providers::UserScopePref { read, write, admin };
tracing::debug!(
method = "composio.set_user_scopes",
toolkit = %toolkit,
read = pref.read,
write = pref.write,
admin = pref.admin,
"[composio:scopes] handler entry"
);
let memory = match crate::openhuman::memory::global::client_if_ready() {
Some(m) => m,
None => {
tracing::error!(
method = "composio.set_user_scopes",
toolkit = %toolkit,
"[composio:scopes] memory client not initialised — cannot persist pref"
);
return Err("memory client not initialised".to_string());
}
};
if let Err(e) = super::providers::user_scopes::save(&memory, &toolkit, pref).await {
tracing::error!(
method = "composio.set_user_scopes",
toolkit = %toolkit,
error = %e,
"[composio:scopes] save failed"
);
return Err(e);
}
tracing::debug!(
method = "composio.set_user_scopes",
toolkit = %toolkit,
read = pref.read,
write = pref.write,
admin = pref.admin,
"[composio:scopes] handler exit"
);
to_json(crate::rpc::RpcOutcome::new(pref, vec![]))
})
}
// ── Param helpers ───────────────────────────────────────────────────
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
@@ -535,6 +692,8 @@ mod tests {
"get_user_profile",
"sync",
"list_trigger_history",
"get_user_scopes",
"set_user_scopes",
];
for k in keys {
let s = schemas(k);
+145 -4
View File
@@ -27,6 +27,106 @@ use serde_json::{json, Value};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult};
use super::client::ComposioClient;
use super::providers::{
catalog_for_toolkit, classify_unknown, find_curated, get_provider, load_user_scope_or_default,
post_process, toolkit_from_slug, ToolScope, UserScopePref,
};
/// Decision returned by [`evaluate_tool_visibility`].
enum ToolDecision {
/// Action is curated for this toolkit and user scope allows it.
Allow,
/// Action exists in the curated list but the user's scope blocks
/// it. `scope` is the curated classification.
BlockedByScope { scope: ToolScope },
/// Action is not in the toolkit's curated whitelist (and the
/// toolkit has one). Hidden / rejected.
NotCurated,
/// Toolkit has no curated catalog — pass through, but still gate by
/// the user scope using the [`classify_unknown`] heuristic.
PassthroughCheckScope { scope: ToolScope },
}
/// Decide whether a Composio action slug should be visible / executable
/// for the current user, given the registered provider's curated list
/// (if any) and the user's stored scope preference.
async fn evaluate_tool_visibility(slug: &str) -> ToolDecision {
let Some(toolkit) = toolkit_from_slug(slug) else {
// Unparseable slug — let the backend return its own error.
return ToolDecision::Allow;
};
let pref = load_user_scope_or_default(&toolkit).await;
// Prefer a registered provider's curated list; fall back to the
// static toolkit→catalog map so toolkits without a native provider
// (e.g. github) still get whitelist enforcement.
let catalog = get_provider(&toolkit)
.and_then(|p| p.curated_tools())
.or_else(|| catalog_for_toolkit(&toolkit));
match catalog {
Some(catalog) => match find_curated(catalog, slug) {
Some(curated) if pref.allows(curated.scope) => ToolDecision::Allow,
Some(curated) => ToolDecision::BlockedByScope {
scope: curated.scope,
},
None => ToolDecision::NotCurated,
},
None => {
let scope = classify_unknown(slug);
if pref.allows(scope) {
ToolDecision::PassthroughCheckScope { scope }
} else {
ToolDecision::BlockedByScope { scope }
}
}
}
}
/// Filter a freshly-fetched [`super::types::ComposioToolsResponse`] in
/// place: drop tools that aren't curated for their toolkit and tools
/// whose scope is disabled in the user's pref.
async fn filter_list_tools_response(resp: &mut super::types::ComposioToolsResponse) {
let before = resp.tools.len();
// Compute keep/drop decisions sequentially (the await means we
// can't fold this into a single sync `retain` closure). Then zip
// each tool with its decision and collect the survivors — clearer
// than juggling a parallel index alongside `Vec::retain`.
let mut keep: Vec<bool> = Vec::with_capacity(before);
for t in &resp.tools {
let decision = evaluate_tool_visibility(&t.function.name).await;
keep.push(matches!(
decision,
ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. }
));
}
let drained: Vec<_> = resp.tools.drain(..).collect();
resp.tools = drained
.into_iter()
.zip(keep)
.filter_map(|(tool, keep_it)| if keep_it { Some(tool) } else { None })
.collect();
let after = resp.tools.len();
if after != before {
tracing::debug!(
before,
after,
dropped = before - after,
"[composio][scopes] composio_list_tools filtered"
);
}
}
/// Format a user-facing error message for a scope-blocked execution.
fn scope_error_message(slug: &str, scope: ToolScope, pref: UserScopePref) -> String {
format!(
"composio_execute: action `{slug}` is classified `{}` and is disabled in your \
current scope preferences (read={}, write={}, admin={}). Update the toolkit's \
scope preference (composio_set_user_scopes) to enable this category.",
scope.as_str(),
pref.read,
pref.write,
pref.admin,
)
}
// ── composio_list_toolkits ──────────────────────────────────────────
@@ -258,9 +358,12 @@ impl Tool for ComposioListToolsTool {
});
tracing::debug!(?toolkits, "[composio] tool list_tools.execute");
match self.client.list_tools(toolkits.as_deref()).await {
Ok(resp) => Ok(ToolResult::success(
serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()),
)),
Ok(mut resp) => {
filter_list_tools_response(&mut resp).await;
Ok(ToolResult::success(
serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()),
))
}
Err(e) => Ok(ToolResult::error(format!(
"composio_list_tools failed: {e}"
))),
@@ -330,11 +433,41 @@ impl Tool for ComposioExecuteTool {
}
let arguments = args.get("arguments").cloned();
tracing::debug!(tool = %tool, "[composio] tool execute.execute");
// Enforce per-user scope preferences before delegating to backend.
match evaluate_tool_visibility(&tool).await {
ToolDecision::Allow | ToolDecision::PassthroughCheckScope { .. } => {}
ToolDecision::BlockedByScope { scope } => {
let toolkit = toolkit_from_slug(&tool).unwrap_or_default();
let pref = load_user_scope_or_default(&toolkit).await;
let msg = scope_error_message(&tool, scope, pref);
tracing::info!(
tool = %tool,
toolkit = %toolkit,
scope = scope.as_str(),
"[composio][scopes] execute blocked by user scope pref"
);
return Ok(ToolResult::error(msg));
}
ToolDecision::NotCurated => {
let toolkit = toolkit_from_slug(&tool).unwrap_or_default();
tracing::info!(
tool = %tool,
toolkit = %toolkit,
"[composio][scopes] execute blocked: action not in curated whitelist"
);
return Ok(ToolResult::error(format!(
"composio_execute: action `{tool}` is not in the curated whitelist for \
toolkit `{toolkit}`. Use composio_list_tools to see available actions."
)));
}
}
let started = std::time::Instant::now();
let res = self.client.execute_tool(&tool, arguments.clone()).await;
let elapsed_ms = started.elapsed().as_millis() as u64;
match res {
Ok(resp) => {
Ok(mut resp) => {
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ComposioActionExecuted {
tool: tool.clone(),
@@ -344,6 +477,14 @@ impl Tool for ComposioExecuteTool {
elapsed_ms,
},
);
// Per-toolkit post-processing of the upstream payload
// (e.g. gmail HTML → markdown). Only run on successful
// responses; errors are passed through verbatim.
if resp.successful {
if let Some(toolkit) = toolkit_from_slug(&tool) {
post_process::post_process(&toolkit, &tool, &mut resp.data);
}
}
Ok(ToolResult::success(
serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()),
))