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,