From 99ad66364c09018a0f7e9f3ef9b813b2b33083bb Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 27 May 2026 04:42:57 +0530 Subject: [PATCH] feat(composio): add tags query param to GitHub tool list API (#2714) --- app/test/e2e/helpers/rpc-preflight.ts | 1 + .../specs/composio-github-tools-tags.spec.ts | 333 ++++++++++++++++++ scripts/mock-api/routes/integrations.mjs | 49 ++- src/openhuman/agent/debug/mod.rs | 1 + .../agent/harness/subagent_runner/ops.rs | 4 +- src/openhuman/composio/client.rs | 51 ++- src/openhuman/composio/client_tests.rs | 50 ++- src/openhuman/composio/ops.rs | 81 ++++- src/openhuman/composio/ops_test.rs | 6 +- src/openhuman/composio/schemas.rs | 28 +- src/openhuman/composio/tools.rs | 28 +- 11 files changed, 579 insertions(+), 53 deletions(-) create mode 100644 app/test/e2e/specs/composio-github-tools-tags.spec.ts diff --git a/app/test/e2e/helpers/rpc-preflight.ts b/app/test/e2e/helpers/rpc-preflight.ts index 65bf3a182..8592f1fb5 100644 --- a/app/test/e2e/helpers/rpc-preflight.ts +++ b/app/test/e2e/helpers/rpc-preflight.ts @@ -27,6 +27,7 @@ const REQUIRED_RPC_METHODS = [ 'openhuman.webhooks_clear_logs', 'openhuman.webhooks_register_echo', 'openhuman.webhooks_unregister_echo', + 'openhuman.composio_list_tools', 'openhuman.composio_list_available_triggers', 'openhuman.composio_list_triggers', 'openhuman.composio_enable_trigger', diff --git a/app/test/e2e/specs/composio-github-tools-tags.spec.ts b/app/test/e2e/specs/composio-github-tools-tags.spec.ts new file mode 100644 index 000000000..e5c43e208 --- /dev/null +++ b/app/test/e2e/specs/composio-github-tools-tags.spec.ts @@ -0,0 +1,333 @@ +// @ts-nocheck +/** + * Composio GitHub tools — tags query param flow (spec GT). + * + * Verifies that when the core calls + * GET /agent-integrations/composio/tools?toolkits=github&tags= + * the mock backend receives the correct query params and returns a + * tag-filtered tool list that the agent prompt and tool-call flow + * correctly use. + * + * Scenarios: + * GT.1 — composio.list_tools RPC with toolkits=["github"] and tags=["stars"] + * forwards ?toolkits=github&tags=stars and returns only starred tools. + * GT.2 — tags with OR semantics: tags=["stars","repos"] returns the union. + * GT.3 — non-GitHub toolkit ignores tags (tags stripped before forwarding). + * GT.4 — agent prompt "list my starred GitHub repos" triggers a tool call + * that uses the stars tag, and the final reply mentions the repo. + */ +import { waitForApp } from '../helpers/app-helpers'; +import { + clickByTitle, + clickSend, + getSelectedThreadId, + typeIntoComposer, + waitForAssistantReplyContaining, + waitForSocketConnected, +} from '../helpers/chat-harness'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { textExists } from '../helpers/element-helpers'; +import { resetApp } from '../helpers/reset-app'; +import { navigateViaHash } from '../helpers/shared-flows'; +import { + clearRequestLog, + getRequestLog, + resetMockBehavior, + setMockBehavior, + startMockServer, + stopMockServer, +} from '../mock-server'; + +const LOG_PREFIX = '[ComposioGitHubToolsTags]'; +const USER_ID = 'e2e-composio-github-tools-tags'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** A minimal OpenAI function-calling tool object for a given action slug. */ +function makeTool(name: string, description = '') { + return { + type: 'function', + function: { + name, + description: description || name, + parameters: { type: 'object', properties: {} }, + }, + }; +} + +const STARS_TOOLS = [ + makeTool('GITHUB_LIST_REPOSITORIES_STARRED_BY_THE_AUTHENTICATED_USER', 'List starred repos'), + makeTool('GITHUB_LIST_STARGAZERS', 'List stargazers'), + makeTool('GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER', 'Star a repo'), +]; + +const REPOS_TOOLS = [ + makeTool('GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', 'List repos'), + makeTool('GITHUB_CREATE_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER', 'Create a repo'), +]; + +// ── Seed helper ─────────────────────────────────────────────────────────────── + +function seedGitHubState(): void { + setMockBehavior('composioToolkits', JSON.stringify(['github', 'gmail'])); + setMockBehavior( + 'composioConnections', + JSON.stringify([{ id: 'conn-github', toolkit: 'github', status: 'ACTIVE' }]) + ); + setMockBehavior('composioToolsByTag_stars', JSON.stringify(STARS_TOOLS)); + setMockBehavior('composioToolsByTag_repos', JSON.stringify(REPOS_TOOLS)); +} + +// ── Chat helper ─────────────────────────────────────────────────────────────── + +async function navigateChatAndSend(prompt: string): Promise { + await navigateViaHash('/chat'); + await browser.waitUntil( + async () => { + if (await getSelectedThreadId()) return true; + if (await textExists('No messages yet')) return true; + return textExists('Type a message'); + }, + { timeout: 15_000, timeoutMsg: 'Chat surface did not mount' } + ); + if (!(await getSelectedThreadId())) { + const clicked = + (await clickByTitle('New thread', 8_000)) || + (await clickByTitle('New thread (/new)', 3_000)) || + (await browser.execute(() => { + const btn = Array.from(document.querySelectorAll('button')).find( + b => (b.textContent ?? '').trim() === 'New' + ) as HTMLButtonElement | undefined; + if (!btn) return false; + btn.click(); + return true; + })); + expect(clicked).toBe(true); + await browser.waitUntil(async () => await getSelectedThreadId(), { + timeout: 8_000, + timeoutMsg: 'thread.selectedThreadId never populated', + }); + } + await typeIntoComposer(prompt); + const socketReady = await waitForSocketConnected(30_000); + if (!socketReady) { + console.warn(`${LOG_PREFIX} socket did not connect within 30s — send may fail`); + } + expect( + await browser.waitUntil(async () => await clickSend(), { + timeout: 15_000, + timeoutMsg: 'Send button never enabled', + }) + ).toBe(true); + console.log(`${LOG_PREFIX} Sent prompt: "${prompt.slice(0, 60)}"`); +} + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('Composio GitHub tools — tags query param flow', () => { + before(async function beforeSuite() { + this.timeout(90_000); + console.log(`${LOG_PREFIX} Starting mock server and resetting app`); + await startMockServer(); + await waitForApp(); + await resetApp(USER_ID); + console.log(`${LOG_PREFIX} Suite setup complete`); + }); + + after(async () => { + resetMockBehavior(); + await stopMockServer(); + console.log(`${LOG_PREFIX} Suite teardown complete`); + }); + + // ── GT.1 — Single tag forwarded and filtered ───────────────────────────── + + it('GT.1 — composio.list_tools with tags=["stars"] returns only starred-tools and hits mock with correct query', async function () { + this.timeout(60_000); + console.log(`${LOG_PREFIX} GT.1: begin`); + + clearRequestLog(); + resetMockBehavior(); + seedGitHubState(); + + const result = await callOpenhumanRpc('openhuman.composio_list_tools', { + toolkits: ['github'], + tags: ['stars'], + }); + + expect(result.ok).toBe(true); + // RpcOutcome with logs serializes as { result: value, logs: [...] }; + // without logs it returns value directly. Unwrap both shapes. + const raw = result.result as any; + const tools: Array<{ function: { name: string } }> = raw?.result?.tools ?? raw?.tools ?? []; + + console.log(`${LOG_PREFIX} GT.1: received ${tools.length} tool(s)`); + expect(tools.length).toBe(STARS_TOOLS.length); + + const names = tools.map(t => t.function.name); + expect(names).toContain('GITHUB_LIST_REPOSITORIES_STARRED_BY_THE_AUTHENTICATED_USER'); + expect(names).toContain('GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER'); + // repos-only tool must not appear + expect(names).not.toContain('GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'); + + // Verify the mock received the correct query params. + const log = getRequestLog() as Array<{ method: string; url: string }>; + const toolsHit = log.find( + r => r.method === 'GET' && r.url.includes('/agent-integrations/composio/tools') + ); + expect(toolsHit).toBeDefined(); + expect(toolsHit!.url).toContain('toolkits=github'); + expect(toolsHit!.url).toContain('tags=stars'); + + console.log(`${LOG_PREFIX} GT.1: PASSED — mock hit: ${toolsHit!.url}`); + }); + + // ── GT.2 — OR semantics across multiple tags ───────────────────────────── + + it('GT.2 — tags=["stars","repos"] returns the union of both tag sets', async function () { + this.timeout(60_000); + console.log(`${LOG_PREFIX} GT.2: begin`); + + clearRequestLog(); + resetMockBehavior(); + seedGitHubState(); + + const result = await callOpenhumanRpc('openhuman.composio_list_tools', { + toolkits: ['github'], + tags: ['stars', 'repos'], + }); + + expect(result.ok).toBe(true); + const raw = result.result as any; + const tools: Array<{ function: { name: string } }> = raw?.result?.tools ?? raw?.tools ?? []; + + const names = tools.map(t => t.function.name); + const expectedCount = STARS_TOOLS.length + REPOS_TOOLS.length; + + console.log(`${LOG_PREFIX} GT.2: received ${tools.length} tool(s), expected ${expectedCount}`); + expect(tools.length).toBe(expectedCount); + + // Both tag sets should be present. + expect(names).toContain('GITHUB_LIST_REPOSITORIES_STARRED_BY_THE_AUTHENTICATED_USER'); + expect(names).toContain('GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'); + + // Verify the mock URL carries both tags (comma-separated). + const log = getRequestLog() as Array<{ method: string; url: string }>; + const toolsHit = log.find( + r => r.method === 'GET' && r.url.includes('/agent-integrations/composio/tools') + ); + expect(toolsHit).toBeDefined(); + expect(toolsHit!.url).toContain('tags=stars'); + expect(toolsHit!.url).toContain('repos'); + + console.log(`${LOG_PREFIX} GT.2: PASSED`); + }); + + // ── GT.3 — Non-GitHub toolkit strips tags ──────────────────────────────── + + it('GT.3 — tags are ignored when toolkit is not github', async function () { + this.timeout(60_000); + console.log(`${LOG_PREFIX} GT.3: begin`); + + clearRequestLog(); + resetMockBehavior(); + setMockBehavior('composioToolkits', JSON.stringify(['gmail'])); + setMockBehavior( + 'composioConnections', + JSON.stringify([{ id: 'conn-gmail', toolkit: 'gmail', status: 'ACTIVE' }]) + ); + // Seed some gmail tools so the endpoint returns something. + const GMAIL_TOOLS = [ + makeTool('GMAIL_GET_MAIL', 'Get mail'), + makeTool('GMAIL_SEND_EMAIL', 'Send email'), + ]; + setMockBehavior('composioTools', JSON.stringify(GMAIL_TOOLS)); + // tags knob for "stars" — must NOT appear in gmail response. + setMockBehavior('composioToolsByTag_stars', JSON.stringify(STARS_TOOLS)); + + const result = await callOpenhumanRpc('openhuman.composio_list_tools', { + toolkits: ['gmail'], + tags: ['stars'], + }); + + expect(result.ok).toBe(true); + const raw = result.result as any; + const tools: Array<{ function: { name: string } }> = raw?.result?.tools ?? raw?.tools ?? []; + const names = tools.map(t => t.function.name); + + console.log(`${LOG_PREFIX} GT.3: received ${tools.length} tool(s): ${names.join(', ')}`); + + // Tags must have been stripped — mock must NOT receive ?tags= for gmail. + const log = getRequestLog() as Array<{ method: string; url: string }>; + const toolsHit = log.find( + r => r.method === 'GET' && r.url.includes('/agent-integrations/composio/tools') + ); + expect(toolsHit).toBeDefined(); + expect(toolsHit!.url).not.toContain('tags='); + // Stars tools must not appear in the gmail response. + expect(names).not.toContain('GITHUB_LIST_REPOSITORIES_STARRED_BY_THE_AUTHENTICATED_USER'); + + console.log(`${LOG_PREFIX} GT.3: PASSED — mock URL: ${toolsHit!.url}`); + }); + + // ── GT.4 — Agent prompt triggers starred-repos tool call ───────────────── + + it('GT.4 — "list my starred GitHub repos" prompt triggers stars-tagged tool call and reply lists repo', async function () { + this.timeout(120_000); + console.log(`${LOG_PREFIX} GT.4: begin`); + + clearRequestLog(); + resetMockBehavior(); + seedGitHubState(); + + const STARRED_REPOS = [ + { name: 'awesome-rust', full_name: 'rust-lang/awesome-rust', stargazers_count: 12000 }, + { name: 'tokio', full_name: 'tokio-rs/tokio', stargazers_count: 24000 }, + ]; + setMockBehavior( + 'composioExecuteResponse_GITHUB_LIST_REPOSITORIES_STARRED_BY_THE_AUTHENTICATED_USER', + JSON.stringify({ repositories: STARRED_REPOS }) + ); + + const CANARY = 'canary-github-stars-a1b2c3'; + const FORCED = [ + { + content: '', + toolCalls: [ + { + id: 'call_github_stars_1', + name: 'GITHUB_LIST_REPOSITORIES_STARRED_BY_THE_AUTHENTICATED_USER', + arguments: JSON.stringify({ per_page: 30 }), + }, + ], + }, + { content: `Your starred repos: awesome-rust, tokio. ${CANARY}` }, + ]; + setMockBehavior('llmForcedResponses', JSON.stringify(FORCED)); + setMockBehavior('llmStreamChunkDelayMs', '10'); + + await navigateChatAndSend('list my starred GitHub repos'); + + await browser.waitUntil(async () => await textExists(CANARY), { + timeout: 60_000, + timeoutMsg: `GT.4: final reply canary "${CANARY}" never appeared`, + }); + expect(await waitForAssistantReplyContaining('awesome-rust', { logPrefix: LOG_PREFIX })).toBe( + true + ); + + const log = getRequestLog() as Array<{ method: string; url: string }>; + const llmHits = log.filter(r => r.method === 'POST' && r.url.includes('/chat/completions')); + console.log(`${LOG_PREFIX} GT.4: ${llmHits.length} LLM completion request(s)`); + expect(llmHits.length).toBeGreaterThanOrEqual(2); + + // Verify the composio execute was called for the forced tool call. + const execHit = log.find( + r => r.method === 'POST' && r.url.includes('/agent-integrations/composio/execute') + ); + expect(execHit).toBeDefined(); + console.log(`${LOG_PREFIX} GT.4: composio execute confirmed — ${execHit!.url}`); + + console.log(`${LOG_PREFIX} GT.4: PASSED`); + }); +}); diff --git a/scripts/mock-api/routes/integrations.mjs b/scripts/mock-api/routes/integrations.mjs index 00d894d62..a640400e9 100644 --- a/scripts/mock-api/routes/integrations.mjs +++ b/scripts/mock-api/routes/integrations.mjs @@ -333,7 +333,54 @@ export function handleIntegrations(ctx) { method === "GET" && /^\/agent-integrations\/composio\/tools\/?(\?.*)?$/.test(url) ) { - json(res, 200, { success: true, data: { tools: [] } }); + // Parse toolkits and tags from the query string. + const qs = url.includes("?") ? new URLSearchParams(url.split("?")[1]) : new URLSearchParams(); + const toolkitsParam = qs.get("toolkits") ?? ""; + const tagsParam = qs.get("tags") ?? ""; + const requestedToolkits = toolkitsParam ? toolkitsParam.split(",").map(t => t.trim().toLowerCase()).filter(Boolean) : []; + const requestedTags = tagsParam ? tagsParam.split(",").map(t => t.trim().toLowerCase()).filter(Boolean) : []; + + // Allow tests to inject per-tag tool lists via + // composioToolsByTag_ (e.g. "composioToolsByTag_stars") + // or a catch-all composioTools knob (array of tool objects). + // Falls back to [] when no knob is set. + let tools = []; + + // Mirror the Rust gate: tags are only honoured when no toolkit filter is + // active or the toolkit list includes GitHub. + const hasGithubToolkit = + requestedToolkits.length === 0 || requestedToolkits.includes("github"); + const effectiveTags = hasGithubToolkit ? requestedTags : []; + + if (effectiveTags.length > 0) { + // OR semantics: union across all requested tags. + const seen = new Set(); + for (const tag of effectiveTags) { + const knobKey = `composioToolsByTag_${tag}`; + const tagTools = parseBehaviorJson(knobKey, null); + if (Array.isArray(tagTools)) { + for (const t of tagTools) { + const name = t?.function?.name ?? t?.name ?? JSON.stringify(t); + if (!seen.has(name)) { + seen.add(name); + tools.push(t); + } + } + } + } + } else { + tools = parseBehaviorJson("composioTools", []); + // Filter by toolkits when requested and the knob returns a list with a + // "function.name" slug we can match (e.g. "GITHUB_*"). + if (requestedToolkits.length > 0 && tools.length > 0) { + tools = tools.filter(t => { + const name = (t?.function?.name ?? t?.name ?? "").toUpperCase(); + return requestedToolkits.some(tk => name.startsWith(tk.toUpperCase() + "_")); + }); + } + } + + json(res, 200, { success: true, data: { tools } }); return true; } diff --git a/src/openhuman/agent/debug/mod.rs b/src/openhuman/agent/debug/mod.rs index 8b3303e5b..87bab059d 100644 --- a/src/openhuman/agent/debug/mod.rs +++ b/src/openhuman/agent/debug/mod.rs @@ -297,6 +297,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result { - match crate::openhuman::composio::fetch_toolkit_actions(client, tk).await { + match crate::openhuman::composio::fetch_toolkit_actions(client, tk, None) + .await + { Ok(actions) if !actions.is_empty() => actions, Ok(_) => { tracing::debug!( diff --git a/src/openhuman/composio/client.rs b/src/openhuman/composio/client.rs index d55a80501..12d66f0cd 100644 --- a/src/openhuman/composio/client.rs +++ b/src/openhuman/composio/client.rs @@ -135,21 +135,44 @@ impl ComposioClient { // ── Tools ─────────────────────────────────────────────────────── - /// `GET /agent-integrations/composio/tools?toolkits=` — fetch - /// OpenAI function-calling schemas. Omit `toolkits` to get every - /// enabled toolkit's tools. - pub async fn list_tools(&self, toolkits: Option<&[String]>) -> Result { - let path = match toolkits { - Some(list) if !list.is_empty() => { - let joined = list - .iter() - .map(|t| t.trim()) - .filter(|t| !t.is_empty()) - .collect::>() - .join(","); - format!("/agent-integrations/composio/tools?toolkits={joined}") + /// `GET /agent-integrations/composio/tools?toolkits=&tags=` — fetch + /// OpenAI function-calling schemas. Omit `toolkits` to get every enabled + /// toolkit's tools. `tags` narrows by Composio action tag (OR semantics — + /// multiple tags broaden the result). + pub async fn list_tools( + &self, + toolkits: Option<&[String]>, + tags: Option<&[String]>, + ) -> Result { + let mut params: Vec = Vec::new(); + if let Some(list) = toolkits { + let joined = list + .iter() + .map(|t| t.trim()) + .filter(|t| !t.is_empty()) + .map(|t| urlencoding::encode(t).into_owned()) + .collect::>() + .join(","); + if !joined.is_empty() { + params.push(format!("toolkits={joined}")); } - _ => "/agent-integrations/composio/tools".to_string(), + } + if let Some(list) = tags { + let joined = list + .iter() + .map(|t| t.trim()) + .filter(|t| !t.is_empty()) + .map(|t| urlencoding::encode(t).into_owned()) + .collect::>() + .join(","); + if !joined.is_empty() { + params.push(format!("tags={joined}")); + } + } + let path = if params.is_empty() { + "/agent-integrations/composio/tools".to_string() + } else { + format!("/agent-integrations/composio/tools?{}", params.join("&")) }; tracing::debug!(path = %path, "[composio] list_tools"); self.inner.get::(&path).await diff --git a/src/openhuman/composio/client_tests.rs b/src/openhuman/composio/client_tests.rs index 0c52da5e2..494924b4f 100644 --- a/src/openhuman/composio/client_tests.rs +++ b/src/openhuman/composio/client_tests.rs @@ -306,16 +306,21 @@ async fn list_tools_filters_pass_through_as_csv_query_param() { let app = Router::new().route( "/agent-integrations/composio/tools", get(|Query(q): Query>| async move { - let filter = q.get("toolkits").cloned().unwrap_or_default(); - // Echo the requested filter back in the payload so the - // test can assert it reached the server correctly. + let toolkits = q.get("toolkits").cloned().unwrap_or_default(); + let tags = q.get("tags").cloned().unwrap_or_default(); + // Echo both filters back so the test can assert they reached the server. + let echo = if tags.is_empty() { + format!("ECHO_{toolkits}") + } else { + format!("ECHO_{toolkits}_TAGS_{tags}") + }; Json(json!({ "success": true, "data": { "tools": [{ "type": "function", "function": { - "name": format!("ECHO_{filter}"), + "name": echo, "description": "echo", "parameters": {} } @@ -327,24 +332,51 @@ async fn list_tools_filters_pass_through_as_csv_query_param() { let base = start_mock_backend(app).await; let client = build_client_for(base); - // No filter: URL should lack `toolkits` query - let resp_all = client.list_tools(None).await.unwrap(); + // No filter: URL should lack both query params + let resp_all = client.list_tools(None, None).await.unwrap(); assert_eq!(resp_all.tools.len(), 1); assert_eq!(resp_all.tools[0].function.name, "ECHO_"); - // With filter: CSV-joined + // toolkits only: CSV-joined let resp_filtered = client - .list_tools(Some(&["gmail".to_string(), "notion".to_string()])) + .list_tools(Some(&["gmail".to_string(), "notion".to_string()]), None) .await .unwrap(); assert_eq!(resp_filtered.tools[0].function.name, "ECHO_gmail,notion"); // Whitespace entries should be dropped before joining let resp_trimmed = client - .list_tools(Some(&["gmail".to_string(), " ".to_string()])) + .list_tools(Some(&["gmail".to_string(), " ".to_string()]), None) .await .unwrap(); assert_eq!(resp_trimmed.tools[0].function.name, "ECHO_gmail"); + + // tags only + let resp_tags = client + .list_tools(None, Some(&["readOnlyHint".to_string()])) + .await + .unwrap(); + assert_eq!(resp_tags.tools[0].function.name, "ECHO__TAGS_readOnlyHint"); + + // toolkits + tags both forwarded + let resp_both = client + .list_tools( + Some(&["github".to_string()]), + Some(&["stars".to_string(), "repos".to_string()]), + ) + .await + .unwrap(); + assert_eq!( + resp_both.tools[0].function.name, + "ECHO_github_TAGS_stars,repos" + ); + + // Empty tags slice treated as no filter + let resp_empty_tags = client + .list_tools(Some(&["gmail".to_string()]), Some(&[])) + .await + .unwrap(); + assert_eq!(resp_empty_tags.tools[0].function.name, "ECHO_gmail"); } #[tokio::test] diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index bab78be03..e90240ae1 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -8,6 +8,29 @@ //! These ops are also callable directly from other domains (e.g. the //! agent harness) when they need composio data at runtime. +/// Toolkits that honour the `tags` query param on the backend tool-list endpoint. +/// Expand this list when a new toolkit gains tag support. +const TAG_QUERYABLE_TOOLKITS: &[&str] = &["github"]; + +/// Returns `true` when `tags` should be forwarded to the backend. +/// +/// Tags are forwarded when no toolkit filter is active (`None` / empty slice) +/// or when at least one requested toolkit is in [`TAG_QUERYABLE_TOOLKITS`]. +/// This is `pub(crate)` so `tools.rs` can reuse it without duplicating the list. +pub(crate) fn should_forward_tags(toolkits: Option<&[String]>) -> bool { + match toolkits { + None => true, + Some(kits) => { + kits.is_empty() + || kits.iter().any(|k| { + TAG_QUERYABLE_TOOLKITS + .iter() + .any(|t| k.trim().eq_ignore_ascii_case(t)) + }) + } + } +} + use crate::openhuman::config::Config; use crate::openhuman::memory::MemoryClient; use crate::openhuman::memory_store::chunks::store as memory_tree_store; @@ -680,8 +703,14 @@ fn dedupe_memory_targets(targets: Vec) -> Vec>, + tags: Option>, ) -> OpResult> { - tracing::debug!(?toolkits, "[composio] rpc list_tools"); + let effective_tags = if should_forward_tags(toolkits.as_deref()) { + tags + } else { + None + }; + tracing::debug!(?toolkits, ?effective_tags, "[composio] rpc list_tools"); // Route through the mode-aware factory. In direct mode the backend // tool catalogue (which is shaped by the tinyhumans-tenant // allowlist + curated whitelist) does NOT apply — the user's @@ -694,10 +723,13 @@ pub async fn composio_list_tools( match kind { ComposioClientKind::Backend(client) => { tracing::debug!("[composio] list_tools: backend variant"); - let resp = client.list_tools(toolkits.as_deref()).await.map_err(|e| { - report_composio_op_error("list_tools", &e); - format!("[composio] list_tools failed: {e:#}") - })?; + let resp = client + .list_tools(toolkits.as_deref(), effective_tags.as_deref()) + .await + .map_err(|e| { + report_composio_op_error("list_tools", &e); + format!("[composio] list_tools failed: {e:#}") + })?; let count = resp.tools.len(); Ok(RpcOutcome::new( resp, @@ -1830,7 +1862,10 @@ async fn fetch_connected_integrations_uncached( let tools = if connected_slugs_for_tools.is_empty() { Vec::new() } else { - match client.list_tools(Some(&connected_slugs_for_tools)).await { + match client + .list_tools(Some(&connected_slugs_for_tools), None) + .await + { Ok(resp) => resp.tools, Err(e) => { tracing::warn!( @@ -1898,21 +1933,23 @@ async fn fetch_connected_integrations_uncached( // (definitional source). Failure is non-fatal — we fall // back to empty tools and let lazy resolution handle it. let tools = match super::client::build_composio_client(config) { - Some(backend_client) => match backend_client.list_tools(Some(&allowlist)).await { - Ok(resp) => { - tracing::debug!( + Some(backend_client) => { + match backend_client.list_tools(Some(&allowlist), None).await { + Ok(resp) => { + tracing::debug!( count = resp.tools.len(), "[composio-direct] fetch_connected_integrations: pulled tool schemas from backend (tenant-agnostic definitional source)" ); - resp.tools - } - Err(e) => { - tracing::info!( + resp.tools + } + Err(e) => { + tracing::info!( "[composio-direct] fetch_connected_integrations: backend list_tools failed (will use lazy fallback at delegation time): {e:#}" ); - Vec::new() + Vec::new() + } } - }, + } None => { tracing::info!( "[composio-direct] fetch_connected_integrations: no backend session for schema fetch; lazy fallback at delegation time" @@ -2151,6 +2188,10 @@ async fn fetch_connected_integrations_uncached( /// `fetch_connected_integrations_uncached`'s own namespacing rule so /// siblings like `github` / `git` don't leak into each other's buckets. /// +/// `tags` narrows the result by Composio action tag (OR semantics). Only +/// honoured for the GitHub toolkit; passed through to `list_tools` so the +/// backend can skip the repo-list force-include and return a focused set. +/// /// Returns an empty vec when the backend has no actions for the /// toolkit (valid steady state for a freshly-authorised integration /// whose catalogue hasn't been published yet). Returns `Err` only for @@ -2158,14 +2199,20 @@ async fn fetch_connected_integrations_uncached( pub async fn fetch_toolkit_actions( client: &ComposioClient, toolkit: &str, + tags: Option<&[String]>, ) -> anyhow::Result> { let toolkit_slug = toolkit.trim(); if toolkit_slug.is_empty() { anyhow::bail!("fetch_toolkit_actions: toolkit must not be empty"); } - tracing::debug!(toolkit = %toolkit_slug, "[composio] fetch_toolkit_actions"); + let effective_tags = if should_forward_tags(Some(&[toolkit_slug.to_string()])) { + tags + } else { + None + }; + tracing::debug!(toolkit = %toolkit_slug, ?effective_tags, "[composio] fetch_toolkit_actions"); let resp = client - .list_tools(Some(&[toolkit_slug.to_string()])) + .list_tools(Some(&[toolkit_slug.to_string()]), effective_tags) .await .map_err(|e| anyhow::anyhow!("list_tools failed for toolkit `{toolkit_slug}`: {e}"))?; let action_prefix = format!("{}_", toolkit_slug.to_uppercase()); diff --git a/src/openhuman/composio/ops_test.rs b/src/openhuman/composio/ops_test.rs index f82ba7ffa..90416fa41 100644 --- a/src/openhuman/composio/ops_test.rs +++ b/src/openhuman/composio/ops_test.rs @@ -123,7 +123,7 @@ async fn composio_delete_connection_errors_without_session() { async fn composio_list_tools_errors_without_session() { let tmp = tempfile::tempdir().unwrap(); let config = test_config(&tmp); - let err = composio_list_tools(&config, None).await.unwrap_err(); + let err = composio_list_tools(&config, None, None).await.unwrap_err(); // Same tolerance as `composio_list_toolkits_errors_without_session`. assert!( err.to_lowercase().contains("composio") @@ -668,7 +668,7 @@ async fn composio_list_tools_via_mock_with_filter() { let base = start_mock_backend(app).await; let tmp = tempfile::tempdir().unwrap(); let config = config_with_backend(&tmp, base); - let outcome = composio_list_tools(&config, Some(vec!["gmail".into()])) + let outcome = composio_list_tools(&config, Some(vec!["gmail".into()]), None) .await .unwrap(); assert_eq!(outcome.value.tools.len(), 2); @@ -1536,7 +1536,7 @@ async fn composio_list_connections_routes_through_direct_mode() { async fn composio_list_tools_in_direct_mode_does_not_fall_back_to_backend() { let tmp = tempfile::tempdir().unwrap(); let config = direct_mode_config(&tmp); - let result = composio_list_tools(&config, None).await; + let result = composio_list_tools(&config, None, None).await; match result { Ok(outcome) => { // If the prefetch returns empty connections (test env may diff --git a/src/openhuman/composio/schemas.rs b/src/openhuman/composio/schemas.rs index 6fb28e6e5..071a3ceb7 100644 --- a/src/openhuman/composio/schemas.rs +++ b/src/openhuman/composio/schemas.rs @@ -313,12 +313,25 @@ pub fn schemas(function: &str) -> ControllerSchema { function: "list_tools", description: "List OpenAI-function-calling tool schemas for one or more Composio toolkits.", - inputs: vec![FieldSchema { - name: "toolkits", - ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))), - comment: "Optional list of toolkit slugs to filter by. Omit to get all.", - required: false, - }], + inputs: vec![ + FieldSchema { + name: "toolkits", + ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new( + TypeSchema::String, + )))), + comment: "Optional list of toolkit slugs to filter by. Omit to get all.", + required: false, + }, + FieldSchema { + name: "tags", + ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new( + TypeSchema::String, + )))), + comment: "Optional Composio action tags to filter by (OR semantics — \ + multiple tags broaden the result). Case-insensitive.", + required: false, + }, + ], outputs: vec![FieldSchema { name: "tools", ty: TypeSchema::Json, @@ -778,7 +791,8 @@ fn handle_list_tools(params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; let toolkits = read_optional::>(¶ms, "toolkits")?; - to_json(super::ops::composio_list_tools(&config, toolkits).await?) + let tags = read_optional::>(¶ms, "tags")?; + to_json(super::ops::composio_list_tools(&config, toolkits, tags).await?) }) } diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index b7a03a5ba..551b32bad 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -703,6 +703,14 @@ impl Tool for ComposioListToolsTool { "items": { "type": "string" }, "description": "Optional list of toolkit slugs to filter by." }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional Composio action tags to filter by \ + (OR semantics — multiple tags broaden the result, \ + e.g. [\"readOnlyHint\"] or [\"repos\", \"stars\"]). \ + Case-insensitive." + }, "include_unconnected": { "type": "boolean", "description": "When true, include actions from toolkits the user \ @@ -734,12 +742,27 @@ impl Tool for ComposioListToolsTool { .filter_map(|v| v.as_str().map(str::to_string)) .collect::>() }); + // tags is only forwarded to the backend when the request is explicitly + // scoped to GitHub — it is the one toolkit where the backend honours the + // param (other toolkits ignore it and passing it could cause unintended + // filtering on future toolkit expansions). + let raw_tags = args.get("tags").and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect::>() + }); + let tags = if super::ops::should_forward_tags(toolkits.as_deref()) { + raw_tags + } else { + None + }; let include_unconnected = args .get("include_unconnected") .and_then(Value::as_bool) .unwrap_or(false); tracing::debug!( ?toolkits, + ?tags, include_unconnected, prefer_markdown = options.prefer_markdown, "[composio] tool list_tools.execute" @@ -796,7 +819,10 @@ impl Tool for ComposioListToolsTool { } }; - match client.list_tools(toolkits.as_deref()).await { + match client + .list_tools(toolkits.as_deref(), tags.as_deref()) + .await + { Ok(mut resp) => { filter_list_tools_response(&mut resp).await; let mut connected_toolkits: Option> = None;