Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion crates/tinymemory-bus/src/composio/catalogs/descriptions.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Human-readable capability summaries for Composio toolkit slugs.
//! Human-readable capability summaries for Composio toolkit slugs, plus what
//! the toolkit's actions hand back.

/// Human-readable capability summary for a Composio toolkit slug.
///
Expand Down Expand Up @@ -63,3 +64,58 @@ pub fn toolkit_description(slug: &str) -> &'static str {
_ => "Interact with this connected service via its available actions",
}
}

/// What a toolkit's actions hand back, and which field feeds which follow-up
/// action. `None` for a toolkit we have not established this for.
///
/// [`toolkit_description`] answers "what can this service do", which is an
/// **input**-side question — and so is everything else the model reads before
/// calling: the tool catalogue, the parameter schema. Nothing tells it what
/// comes back. So a list action returns records keyed by id, the model has no
/// statement that the id is the handle for the detail it actually wanted, and it
/// re-issues the same list call. That was observed live against Gmail.
///
/// The rule for adding an entry: name only action slugs this crate's curated
/// catalogues carry, and say only what a caller has established by observing
/// those actions. A toolkit nobody has checked gets no entry — a guess about a
/// response is worse here than silence, because the model will act on it.
///
/// **Do not describe field-by-field record shapes here.** A note may say what a
/// result *contains* and what to do with it, not how it is serialized. Composio
/// dispatch prefers the backend's rendered `markdownFormatted` body and falls
/// back to the JSON envelope only when that is absent, so a note reciting JSON
/// keys is true on one of two renderings. An earlier revision of this text made
/// exactly that mistake and told the model every Gmail read action answers with
/// a markdown body, when only `GMAIL_FETCH_EMAILS` carries one.
pub fn toolkit_result_notes(slug: &str) -> Option<&'static str> {
match slug {
// Slugs: `gmail::GMAIL_CURATED`.
//
// The thread/message distinction is the whole point of this entry. Live,
// a sub-agent searched with GMAIL_LIST_THREADS, got no message body back,
// and reported that mail which does exist could not be found.
"gmail" => Some(
"GMAIL_LIST_THREADS answers with thread ids, a one-line snippet, and a message \
count — never a message body, so a thread whose snippet looks right still has \
to be read. Pass a thread id to GMAIL_FETCH_MESSAGE_BY_THREAD_ID, or a message \
id to GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID, to get the body; GMAIL_FETCH_EMAILS \
carries one already. Bodies are the backend's rendered text, not the raw \
message, and attachments arrive as a filename and type that GMAIL_GET_ATTACHMENT \
fetches. Repeating a search returns the same snippets, so read the thread \
instead of searching again.",
),
// Slugs: `messaging::SLACK_CURATED`.
"slack" => Some(
"SLACK_LIST_CONVERSATIONS answers with a channel id per channel, and that id is \
the channel argument SLACK_FETCH_CONVERSATION_HISTORY and the post actions take. \
History entries identify their author by Slack user id, not display name, so \
resolve it with SLACK_FIND_USERS before quoting a name, and identify themselves \
by a ts timestamp, which is what threads and reactions key on.",
),
_ => None,
}
}

#[cfg(test)]
#[path = "descriptions_tests.rs"]
mod tests;
61 changes: 61 additions & 0 deletions crates/tinymemory-bus/src/composio/catalogs/descriptions_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Tests for the surrounding module.
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]

use super::*;

/// Every action slug these notes tell the model to call must be one the
/// toolkit actually exposes. A note naming a slug that was renamed or
/// dropped from the curated list is worse than no note: it sends the model
/// after a tool that is not in its list.
#[test]
fn result_notes_only_name_curated_action_slugs() {
// Each toolkit is checked against its OWN catalogue. Pooling them would
// let a Gmail note name a Slack-only action and still pass, which is the
// mistake most likely to be made when editing prose that mentions both.
let gmail: Vec<&str> = crate::composio::catalogs::gmail::GMAIL_CURATED
.iter()
.map(|tool| tool.slug)
.collect();
let slack: Vec<&str> = crate::composio::catalogs::messaging::SLACK_CURATED
.iter()
.map(|tool| tool.slug)
.collect();

for (slug, curated) in [("gmail", &gmail), ("slack", &slack)] {
let notes = toolkit_result_notes(slug).expect("both toolkits have notes");
for word in notes.split(|c: char| !(c.is_ascii_uppercase() || c == '_')) {
// An all-caps underscored token in this prose is an action slug.
if word.len() > 6 && word.contains('_') {
assert!(
curated.contains(&word),
"{slug} notes name `{word}`, which is not one of {slug}'s curated actions"
);
}
}
}
}

/// A toolkit nobody has established a result shape for gets no entry — a
/// guess about a response is worse here than silence.
#[test]
fn result_notes_absent_for_unestablished_toolkits() {
assert!(toolkit_result_notes("notion").is_none());
assert!(toolkit_result_notes("definitely_not_a_toolkit").is_none());
}

/// The failure this entry exists for: a sub-agent searched threads, got
/// snippets rather than bodies, and reported that mail which does exist
/// could not be found. The note has to name both halves — that a thread
/// listing has no body, and which action produces one.
#[test]
fn gmail_notes_separate_finding_a_thread_from_reading_it() {
let notes = toolkit_result_notes("gmail").expect("gmail has notes");
assert!(
notes.contains("GMAIL_LIST_THREADS") && notes.contains("never a message body"),
"must say a thread listing carries no body: {notes}"
);
assert!(
notes.contains("GMAIL_FETCH_MESSAGE_BY_THREAD_ID"),
"must name the action that reads the thread: {notes}"
);
}
2 changes: 1 addition & 1 deletion crates/tinymemory-bus/src/composio/catalogs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ use super::scopes::{
classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope, UserScopePref,
};

pub use descriptions::toolkit_description;
pub use descriptions::{toolkit_description, toolkit_result_notes};

/// Every toolkit the capability surface reports on, in display order.
pub const CAPABILITY_TOOLKITS: &[&str] = &[
Expand Down
3 changes: 2 additions & 1 deletion crates/tinymemory-bus/src/composio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,6 @@ pub use tasks::{GithubFetchMode, NormalizedTask, TaskContainer, TaskFetchFilter,
pub use catalogs::{
catalog_for_toolkit, curated_scope_for, has_native_provider, is_action_visible_with_pref,
native_provider_sync_interval_secs, parse_sync_interval_override, sync_interval_env_var,
toolkit_description, toolkit_has_scope, CAPABILITY_TOOLKITS, NATIVE_PROVIDERS,
toolkit_description, toolkit_has_scope, toolkit_result_notes, CAPABILITY_TOOLKITS,
NATIVE_PROVIDERS,
};