feat(composio): add tags query param to GitHub tool list API (#2714)

This commit is contained in:
Mega Mind
2026-05-27 04:42:57 +05:30
committed by GitHub
parent 5c035d8937
commit 99ad66364c
11 changed files with 579 additions and 53 deletions
+1
View File
@@ -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',
@@ -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=<csv>
* 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<void> {
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`);
});
});
+48 -1
View File
@@ -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_<tag> (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;
}
+1
View File
@@ -297,6 +297,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result<Dum
match crate::openhuman::composio::fetch_toolkit_actions(
composio_client,
&integration.toolkit,
None,
)
.await
{
@@ -740,7 +740,9 @@ async fn run_typed_mode(
// behaviour on network failure.
let fresh_actions = match &client_kind {
Some(ComposioClientKind::Backend(client)) => {
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!(
+37 -14
View File
@@ -135,21 +135,44 @@ impl ComposioClient {
// ── Tools ───────────────────────────────────────────────────────
/// `GET /agent-integrations/composio/tools?toolkits=<csv>` — fetch
/// OpenAI function-calling schemas. Omit `toolkits` to get every
/// enabled toolkit's tools.
pub async fn list_tools(&self, toolkits: Option<&[String]>) -> Result<ComposioToolsResponse> {
let path = match toolkits {
Some(list) if !list.is_empty() => {
let joined = list
.iter()
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(",");
format!("/agent-integrations/composio/tools?toolkits={joined}")
/// `GET /agent-integrations/composio/tools?toolkits=<csv>&tags=<csv>` — 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<ComposioToolsResponse> {
let mut params: Vec<String> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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::<ComposioToolsResponse>(&path).await
+41 -9
View File
@@ -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<HashMap<String, String>>| 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]
+64 -17
View File
@@ -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<MemoryCleanupTarget>) -> Vec<MemoryCleanup
pub async fn composio_list_tools(
config: &Config,
toolkits: Option<Vec<String>>,
tags: Option<Vec<String>>,
) -> OpResult<RpcOutcome<ComposioToolsResponse>> {
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<Vec<ConnectedIntegrationTool>> {
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());
+3 -3
View File
@@ -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
+21 -7
View File
@@ -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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let toolkits = read_optional::<Vec<String>>(&params, "toolkits")?;
to_json(super::ops::composio_list_tools(&config, toolkits).await?)
let tags = read_optional::<Vec<String>>(&params, "tags")?;
to_json(super::ops::composio_list_tools(&config, toolkits, tags).await?)
})
}
+27 -1
View File
@@ -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::<Vec<_>>()
});
// 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::<Vec<_>>()
});
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<HashSet<String>> = None;