mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(composio): add tags query param to GitHub tool list API (#2714)
This commit is contained in:
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>(¶ms, "toolkits")?;
|
||||
to_json(super::ops::composio_list_tools(&config, toolkits).await?)
|
||||
let tags = read_optional::<Vec<String>>(¶ms, "tags")?;
|
||||
to_json(super::ops::composio_list_tools(&config, toolkits, tags).await?)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user