From 75efc80be49522a4bac3d933d7f79b0f43627465 Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 26 Aug 2026 02:26:01 +0530 Subject: [PATCH] Let a host ask whether a toolkit has a sync pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_composio_toolkit_syncable` is the gate that decides whether a Composio connection can become a memory source, and it is engine-internal. A host that cannot ask it has three bad options: keep its own copy of the list and drift, infer the answer from a failed sync — by which point it has already registered the source it should not have — or reach past the contract into the engine crate. OpenHuman does the third today, and it is the last thing keeping the engine's provider re-export alive there (openhuman#5560). `IsToolkitSyncable(toolkit) -> bool` closes it. A predicate rather than the list, because the answer depends on a normalisation the driver owns: it trims and lower-cases before matching. A caller handed the list would have to reimplement that rule, and would be right until the day the rule changed and silently wrong after. The question keeps the rule on the side that owns it, and the cost is one round-trip per connection authorisation rather than per sync. `Unsupported` rather than `Ok(false)` from a driver that cannot enumerate its pipelines: "I have no pipeline for this" and "I cannot tell you" are different answers, and a caller that conflated them would quietly stop registering every memory source while still looking healthy. Defaulted on the trait, so this is additive for any other implementor; the null driver takes the default and answers `Unsupported`. 985 + 113 + 62 + 163 + 134 + 24 tests pass in the root workspace and the module's own; both build clean with --all-targets. --- crates/tinymemory-api/src/null_tests.rs | 4 +++ crates/tinymemory-api/src/provider/sync.rs | 30 +++++++++++++++++++ crates/tinymemory-bus/src/lib.rs | 2 +- crates/tinymemory-bus/src/names.rs | 5 +++- crates/tinymemory-module/src/lib.rs | 1 + crates/tinymemory-module/src/service/mod.rs | 13 ++++++++ crates/tinymemory-module/tests/module_e2e.rs | 1 + .../tinymemory-tinycortex/src/engine/mod.rs | 7 +++++ 8 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-api/src/null_tests.rs b/crates/tinymemory-api/src/null_tests.rs index afde688..7f65606 100644 --- a/crates/tinymemory-api/src/null_tests.rs +++ b/crates/tinymemory-api/src/null_tests.rs @@ -518,6 +518,10 @@ fn every_optional_method_fails_with_its_advertised_family_name() { block_on(driver.bootstrap_connection("gmail", "conn-1")), Capability::SourceSync, ); + assert_unsupported( + block_on(driver.is_toolkit_syncable("gmail")), + Capability::SourceSync, + ); assert_unsupported( block_on(driver.source_sync_state("gmail", "conn-1")), Capability::SourceSync, diff --git a/crates/tinymemory-api/src/provider/sync.rs b/crates/tinymemory-api/src/provider/sync.rs index af81f9d..e0a40a0 100644 --- a/crates/tinymemory-api/src/provider/sync.rs +++ b/crates/tinymemory-api/src/provider/sync.rs @@ -199,6 +199,36 @@ pub trait MemorySourceSync: Send + Sync { Err(MemoryError::unsupported(Capability::SourceSync)) } + /// Whether this driver has a sync pipeline for `toolkit`. + /// + /// # Why a predicate and not the list + /// + /// The answer depends on a normalisation rule — the driver trims and + /// lower-cases before matching — and a caller given the list would have to + /// reimplement that rule to use it. It would then be right until the day + /// the driver's rule changed, and wrong silently after. Asking the question + /// keeps the rule on the side that owns it. + /// + /// # What a caller does with `false` + /// + /// Not "refuse the connection". A toolkit with no pipeline is still a + /// perfectly good agent-tool integration; what it cannot do is become a + /// *memory source*. A host that registers one anyway ships a source that + /// reports healthy and then fails every sync, which is a worse answer than + /// not offering it — so this is the question to ask before registering, + /// not after a sync fails. + /// + /// # Errors + /// + /// [`MemoryError::Unsupported`] from a driver that serves this family but + /// cannot enumerate its pipelines. Deliberately not `Ok(false)`: "I have no + /// pipeline for this" and "I cannot tell you" are different answers, and a + /// caller that conflated them would silently stop registering every source. + async fn is_toolkit_syncable(&self, toolkit: &str) -> Result { + let _ = toolkit; + Err(MemoryError::unsupported(Capability::SourceSync)) + } + async fn source_sync_state( &self, toolkit: &str, diff --git a/crates/tinymemory-bus/src/lib.rs b/crates/tinymemory-bus/src/lib.rs index 2bebc2b..f644259 100644 --- a/crates/tinymemory-bus/src/lib.rs +++ b/crates/tinymemory-bus/src/lib.rs @@ -2,7 +2,7 @@ //! the members that carry them. //! //! TinyMemory ships as a loadable `TinyBus` module: `crates/tinymemory-module` -//! exports one object with 122 members on it, built as a `cdylib`. A host that +//! exports one object with 123 members on it, built as a `cdylib`. A host that //! loads it — OpenHuman — can call into it but cannot `use` anything out of it, //! so the payload vocabulary has to be published as an ordinary library. This //! is that library. diff --git a/crates/tinymemory-bus/src/names.rs b/crates/tinymemory-bus/src/names.rs index d95d5d4..9ebdf9e 100644 --- a/crates/tinymemory-bus/src/names.rs +++ b/crates/tinymemory-bus/src/names.rs @@ -296,6 +296,8 @@ pub mod methods { pub const RUN_SOURCE_SYNC: &str = "RunSourceSync"; /// `BootstrapConnection` — run one connection's first-time bootstrap. pub const BOOTSTRAP_CONNECTION: &str = "BootstrapConnection"; + /// `IsToolkitSyncable` — whether this driver has a pipeline for a toolkit. + pub const IS_TOOLKIT_SYNCABLE: &str = "IsToolkitSyncable"; /// `SourceSyncState` — the persisted cursor and budget for one connection. pub const SOURCE_SYNC_STATE: &str = "SourceSyncState"; /// `SyncAuditLog` — past sync runs, newest first. @@ -321,7 +323,7 @@ pub mod methods { /// The order matters: `tinybus`'s `Interface::members()` returns declaration /// order, and the module compares the two sequences directly rather than as /// sets, so a reordering is caught alongside an addition or a removal. -pub const METHODS: [&str; 122] = [ +pub const METHODS: [&str; 123] = [ methods::DRIVER_ID, methods::CAPABILITIES, methods::HEALTH, @@ -436,6 +438,7 @@ pub const METHODS: [&str; 122] = [ methods::RUN_CONNECTION_SYNC, methods::RUN_SOURCE_SYNC, methods::BOOTSTRAP_CONNECTION, + methods::IS_TOOLKIT_SYNCABLE, methods::SOURCE_SYNC_STATE, methods::SYNC_AUDIT_LOG, methods::ESTIMATE_SYNC_COST_USD, diff --git a/crates/tinymemory-module/src/lib.rs b/crates/tinymemory-module/src/lib.rs index c874dc5..cd0ba88 100644 --- a/crates/tinymemory-module/src/lib.rs +++ b/crates/tinymemory-module/src/lib.rs @@ -773,6 +773,7 @@ mod exports { "RunConnectionSync", "RunSourceSync", "BootstrapConnection", + "IsToolkitSyncable", "SourceSyncState", "SyncAuditLog", "EstimateSyncCostUsd", diff --git a/crates/tinymemory-module/src/service/mod.rs b/crates/tinymemory-module/src/service/mod.rs index bb732a6..29126d6 100644 --- a/crates/tinymemory-module/src/service/mod.rs +++ b/crates/tinymemory-module/src/service/mod.rs @@ -66,6 +66,7 @@ //! //! RunConnectionSync(toolkit, connection_id) -> SyncRunOutcome //! BootstrapConnection(toolkit, connection_id) -> () +//! IsToolkitSyncable(toolkit) -> bool //! RunSourceSync(source_id) -> SyncRunOutcome //! SourceSyncState(toolkit, connection_id) -> Option //! SyncAuditLog(limit) -> [SyncAuditEntry] @@ -1764,6 +1765,18 @@ impl MemoryService { .map_err(|error| into_bus_error(&error)) } + /// Whether this driver has a sync pipeline for one toolkit. + /// + /// Asked rather than answered from a list the caller holds, so the + /// normalisation the driver applies never has to be reimplemented on the + /// far side of the bus. + async fn is_toolkit_syncable(&self, toolkit: String) -> BusResult { + require_family!(self, as_source_sync, Capability::SourceSync) + .is_toolkit_syncable(&toolkit) + .await + .map_err(|error| into_bus_error(&error)) + } + /// The persisted cursor, dedup and budget state for one connection. /// /// `None` is "never synced", which is a state and not an error — a status diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index 7494d6c..315ff45 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -696,6 +696,7 @@ const EXPECTED_METHODS: &[&str] = &[ "RunConnectionSync", "RunSourceSync", "BootstrapConnection", + "IsToolkitSyncable", "SourceSyncState", "SyncAuditLog", "EstimateSyncCostUsd", diff --git a/crates/tinymemory-tinycortex/src/engine/mod.rs b/crates/tinymemory-tinycortex/src/engine/mod.rs index 46e2e1b..0966ec7 100644 --- a/crates/tinymemory-tinycortex/src/engine/mod.rs +++ b/crates/tinymemory-tinycortex/src/engine/mod.rs @@ -2546,6 +2546,13 @@ impl MemorySourceSync for TinycortexProvider { }) } + async fn is_toolkit_syncable(&self, toolkit: &str) -> Result { + // The same predicate `ensure_syncable_toolkit` gates on, exposed rather + // than inferred: a caller that learned this from a failed sync would + // already have registered the source it should not have. + Ok(tinymemory_core::sync::pipelines::host::is_composio_toolkit_syncable(toolkit)) + } + async fn source_sync_state( &self, toolkit: &str,