From 330521f9f71e11f50b27827bff8bf08aa48bcd8b Mon Sep 17 00:00:00 2001 From: yh928 Date: Wed, 29 Jul 2026 15:16:20 +0900 Subject: [PATCH 1/7] fix(composio): gate write actions through approval, reshape agent results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps on the agent's Composio execution surface, both diagnosed live. **Approval (P1).** The human-in-the-loop approval card is raised only for tools whose `external_effect_with_args` is true, but neither `composio_execute` nor the per-action `ComposioActionTool` declared it — so a Composio mail send (`GMAIL_SEND_EMAIL`) fired with no approval prompt at all, even when the user had "ask before sending" configured. The contract gate (schema-presence) and `permission_level = Write` (channel caps) do not raise that card. Both surfaces now report external-effect for a write/admin-scoped action and stay false for a pure read, so a write routes through the `ApprovalGate` while a fetch/list flows through unprompted. Scope is classified synchronously (`resolve_action_scope`'s body has no `await`, so it is reused via `resolve_action_scope_sync`). **Reshape (P4, #2585).** When the agent calls a Composio action directly, a verbose provider envelope — Gmail's full MIME tree under `payload.parts[]` — landed in context on the raw-JSON fallback body. The provider response reshape that slims it (the same one the sync path runs) was only wired into sync; it now runs inline on the agent execute + per-action paths, so `resp.data` is slimmed before it can become the tool body. A backend-rendered `markdown_formatted` body is already clean and unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SRSNnqQsokuGmkbpLoLCGy --- .../integrations/composio/action_tool.rs | 49 ++++++++++++++++- src/openhuman/integrations/composio/tools.rs | 55 ++++++++++++++++++- .../integrations/composio/tools_tests.rs | 25 +++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/openhuman/integrations/composio/action_tool.rs b/src/openhuman/integrations/composio/action_tool.rs index 30a29282be..79cb259907 100644 --- a/src/openhuman/integrations/composio/action_tool.rs +++ b/src/openhuman/integrations/composio/action_tool.rs @@ -149,6 +149,15 @@ impl Tool for ComposioActionTool { PermissionLevel::Write } + fn external_effect_with_args(&self, _args: &Value) -> bool { + // The per-action surface must gate on the approval card exactly like the + // `composio_execute` dispatcher: a write/admin action (this tool's own + // slug) routes through the `ApprovalGate` before it runs; a pure read + // flows through unprompted. Without this the model could send mail / + // create records via a per-action tool with no approval prompt at all. + super::tools::action_mutates_external_state(&self.action_name) + } + fn category(&self) -> ToolCategory { ToolCategory::Workflow } @@ -292,6 +301,9 @@ impl Tool for ComposioActionTool { let effective_connection_id = runtime_connection_id .as_deref() .or(self.connection_id.as_deref()); + // Kept for the post-dispatch envelope reshape (#2585): the dispatch + // consumes `args`, but the reshape reads it for the `raw_html` opt-out. + let reshape_args = args.clone(); let res = super::execute_dispatch::execute_composio_action_kind_with_connection( kind, &self.action_name, @@ -303,7 +315,7 @@ impl Tool for ComposioActionTool { let elapsed_ms = started.elapsed().as_millis() as u64; match res { - Ok(resp) => { + Ok(mut resp) => { crate::core::event_bus::publish_global( crate::core::event_bus::DomainEvent::ComposioActionExecuted { tool: self.action_name.clone(), @@ -313,6 +325,20 @@ impl Tool for ComposioActionTool { elapsed_ms, }, ); + // Slim the provider envelope before it can become the tool body + // (#2585) — the same reshape `ComposioExecuteTool` runs, so a + // per-action Gmail fetch doesn't drop a full MIME tree into the + // agent's context. Only the raw-JSON fallback body serializes + // `resp.data`; a backend-rendered markdown body is unaffected. + if let Some(provider) = super::providers::toolkit_from_slug(&self.action_name) + .and_then(|tk| super::providers::get_provider(&tk)) + { + provider.post_process_action_result( + &self.action_name, + reshape_args.as_ref(), + &mut resp.data, + ); + } // Mirror `ComposioExecuteTool::execute` (composio/tools.rs): // prefer the backend-rendered `markdownFormatted` for LLM // consumption when present, fall back to the raw JSON @@ -745,4 +771,25 @@ mod tests { "direct-mode tool must not surface backend-session artifacts: {direct_msg}" ); } + + #[test] + fn per_action_tool_gates_writes_but_not_reads() { + // The per-action surface must gate on the approval card exactly like the + // dispatcher: a write action routes through the gate, a read does not. + let send = ComposioActionTool::new( + fake_config(), + "GMAIL_SEND_EMAIL".to_string(), + "send".to_string(), + None, + ); + assert!(send.external_effect_with_args(&serde_json::json!({}))); + + let read = ComposioActionTool::new( + fake_config(), + "GMAIL_FETCH_EMAILS".to_string(), + "fetch".to_string(), + None, + ); + assert!(!read.external_effect_with_args(&serde_json::json!({}))); + } } diff --git a/src/openhuman/integrations/composio/tools.rs b/src/openhuman/integrations/composio/tools.rs index feb8f49114..2eefce81e8 100644 --- a/src/openhuman/integrations/composio/tools.rs +++ b/src/openhuman/integrations/composio/tools.rs @@ -74,6 +74,15 @@ enum ToolDecision { /// blocking rather than letting a potentially-mutating action slip /// through uncategorised. pub(super) async fn resolve_action_scope(slug: &str) -> ToolScope { + resolve_action_scope_sync(slug) +} + +/// Synchronous core of [`resolve_action_scope`]. Every lookup it makes — +/// `toolkit_from_slug`, the provider/curated-catalog resolution, and the +/// `classify_unknown` heuristic — is over static data with no `await`, so a +/// caller that cannot be `async` (the `Tool::external_effect_with_args` +/// gate-decision hook) can classify a slug directly. +pub(super) fn resolve_action_scope_sync(slug: &str) -> ToolScope { let Some(toolkit) = toolkit_from_slug(slug) else { return ToolScope::Write; }; @@ -88,6 +97,18 @@ pub(super) async fn resolve_action_scope(slug: &str) -> ToolScope { classify_unknown(slug) } +/// Whether a Composio action slug mutates external state, i.e. is +/// `Write`/`Admin`-scoped. This is the predicate the approval gate keys off — +/// a write/admin Composio action must route through the human-in-the-loop +/// `ApprovalGate` before it runs, while a pure `Read` flows through unprompted +/// (matching the `external_effect` contract on the `Tool` trait). +pub(super) fn action_mutates_external_state(slug: &str) -> bool { + matches!( + resolve_action_scope_sync(slug), + ToolScope::Write | ToolScope::Admin + ) +} + /// Decide whether a Composio action slug should be visible / executable /// for the current user, given the registered provider's curated list /// (if any) and the user's stored scope preference. @@ -1313,6 +1334,18 @@ impl Tool for ComposioExecuteTool { // as write-level to respect channel permission caps. PermissionLevel::Write } + fn external_effect_with_args(&self, args: &Value) -> bool { + // Route a write/admin Composio action (send mail, create issue, delete, + // …) through the approval gate; a pure read flows through unprompted. + // The action slug is the `tool` argument. An empty/absent slug errs on + // the side of gating rather than letting a possibly-mutating call slip + // past the prompt. + args.get("tool") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|slug| !slug.is_empty()) + .is_none_or(action_mutates_external_state) + } fn category(&self) -> ToolCategory { ToolCategory::Workflow } @@ -1441,6 +1474,10 @@ impl Tool for ComposioExecuteTool { Some(since) => super::task_window::apply_window_args(&tool, arguments, since), None => arguments, }; + // Kept for the post-dispatch envelope reshape (#2585): the dispatch + // below consumes `arguments`, but the reshape reads it for the + // `raw_html` opt-out. + let reshape_args = arguments.clone(); // Resolve the client through the mode-aware factory on every // call so a direct-mode toggle takes effect immediately @@ -1490,10 +1527,26 @@ impl Tool for ComposioExecuteTool { // than the window. No-op unless a window is installed AND the // slug is a curated task-fetch action. Runs before the // markdown/JSON body decision so the agent reads filtered data. - let resp = match task_window_since { + let mut resp = match task_window_since { Some(since) => super::task_window::filter_response(&tool, resp, since), None => resp, }; + // Slim the provider envelope before it can become the tool body + // (#2585). A verbose Composio payload — Gmail's full MIME tree + // under `payload.parts[]`, dozens of `Received:` headers — is + // reshaped into one clean record per message. Only the fallback + // body path serializes `resp.data`, so this shrinks exactly the + // raw-JSON case; a backend-rendered `markdown_formatted` body is + // already clean and unaffected. The sync path applies the same + // reshape via `ReshapingExecutor`; here we run it inline on the + // agent's direct call. + if let Some(provider) = toolkit_from_slug(&tool).and_then(|tk| get_provider(&tk)) { + provider.post_process_action_result( + &tool, + reshape_args.as_ref(), + &mut resp.data, + ); + } tracing::info!( tool = %tool, successful = resp.successful, diff --git a/src/openhuman/integrations/composio/tools_tests.rs b/src/openhuman/integrations/composio/tools_tests.rs index b72a9eb48a..b01c552601 100644 --- a/src/openhuman/integrations/composio/tools_tests.rs +++ b/src/openhuman/integrations/composio/tools_tests.rs @@ -1123,3 +1123,28 @@ fn parse_composio_connect_timeout_honors_override_and_zero_opt_out() { // `0` → opt out of the composio-side bound (fall back to the gate TTL). assert_eq!(parse_composio_connect_timeout(Some("0")), None); } + +#[test] +fn execute_tool_gates_writes_but_not_reads_via_external_effect() { + // The approval gate keys off `external_effect_with_args`. A write/admin + // Composio action (send/create/delete) must route through the gate; a pure + // read (fetch/list) must flow through unprompted. Regression: neither + // surface declared external_effect, so mail sends fired with no approval. + let t = ComposioExecuteTool::new(fake_config_arc()); + assert!( + t.external_effect_with_args(&serde_json::json!({ "tool": "GMAIL_SEND_EMAIL" })), + "a send action must be gated" + ); + assert!( + t.external_effect_with_args(&serde_json::json!({ "tool": "GMAIL_DELETE_MESSAGE" })), + "a delete action must be gated" + ); + assert!( + !t.external_effect_with_args(&serde_json::json!({ "tool": "GMAIL_FETCH_EMAILS" })), + "a read action must not prompt" + ); + assert!( + t.external_effect_with_args(&serde_json::json!({})), + "an absent slug errs on the side of gating" + ); +} From 12523784e4b6ec5640b4274066220fa85f29aa71 Mon Sep 17 00:00:00 2001 From: yh928 Date: Fri, 31 Jul 2026 17:39:40 +0900 Subject: [PATCH 2/7] fix(composio): publish the action event after the reshape, as the dispatcher does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ComposioExecuteTool` reshapes then publishes; the per-action tool published then reshaped. The payload names no reshaped field today, so the order is not observable — but two surfaces describing the same action must not disagree about which snapshot the event saw, or the first field added to it diverges silently between them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SRSNnqQsokuGmkbpLoLCGy --- .../integrations/composio/action_tool.rs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/openhuman/integrations/composio/action_tool.rs b/src/openhuman/integrations/composio/action_tool.rs index 79cb259907..e15301fe57 100644 --- a/src/openhuman/integrations/composio/action_tool.rs +++ b/src/openhuman/integrations/composio/action_tool.rs @@ -316,15 +316,6 @@ impl Tool for ComposioActionTool { match res { Ok(mut resp) => { - crate::core::event_bus::publish_global( - crate::core::event_bus::DomainEvent::ComposioActionExecuted { - tool: self.action_name.clone(), - success: resp.successful, - error: resp.error.clone(), - cost_usd: resp.cost_usd, - elapsed_ms, - }, - ); // Slim the provider envelope before it can become the tool body // (#2585) — the same reshape `ComposioExecuteTool` runs, so a // per-action Gmail fetch doesn't drop a full MIME tree into the @@ -339,6 +330,20 @@ impl Tool for ComposioActionTool { &mut resp.data, ); } + // Published after the reshape, matching `ComposioExecuteTool`. + // The payload names no reshaped field today, so the order is not + // observable — but the two surfaces describing the same action + // must not disagree about which snapshot the event saw, or the + // first field added here diverges silently between them. + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::ComposioActionExecuted { + tool: self.action_name.clone(), + success: resp.successful, + error: resp.error.clone(), + cost_usd: resp.cost_usd, + elapsed_ms, + }, + ); // Mirror `ComposioExecuteTool::execute` (composio/tools.rs): // prefer the backend-rendered `markdownFormatted` for LLM // consumption when present, fall back to the raw JSON From 7a910d96d896b738f55f72ed677da1c0ca674f91 Mon Sep 17 00:00:00 2001 From: yh928 Date: Sun, 2 Aug 2026 21:37:20 +0900 Subject: [PATCH 3/7] fix(composio): let a provider reshape supersede the backend markdown rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GMAIL_LIST_THREADS is rendered by the backend as a bare list of thread ids. The payload behind it carries each thread's subject, sender, date, labels, and snippet, and reshape_list_threads lifts them — but both dispatch paths build the model-facing body by preferring markdownFormatted and falling back to the JSON envelope only when absent, so the reshape was written and then never read. Live, a sub-agent searching for mail that does exist got the ids, had nothing to recognise the thread by, and reported it could not be found. ComposioProvider::reshape_supersedes_markdown(slug) lets a provider say its rewrite replaces the rendering; both call sites clear markdown_formatted when it answers true. Per-action, not per-toolkit, because within Gmail the answer differs: GMAIL_FETCH_EMAILS's reshape READS markdownFormatted for the message body, so clearing it there would throw the body away. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SRSNnqQsokuGmkbpLoLCGy --- .../integrations/composio/action_tool.rs | 11 ++ src/openhuman/integrations/composio/tools.rs | 11 ++ .../composio/providers/gmail/post_process.rs | 123 +++++++++++++++++- .../sync/composio/providers/gmail/provider.rs | 49 +++++++ .../memory/sync/composio/providers/traits.rs | 25 ++++ 5 files changed, 217 insertions(+), 2 deletions(-) diff --git a/src/openhuman/integrations/composio/action_tool.rs b/src/openhuman/integrations/composio/action_tool.rs index e15301fe57..cdc5460e1b 100644 --- a/src/openhuman/integrations/composio/action_tool.rs +++ b/src/openhuman/integrations/composio/action_tool.rs @@ -329,6 +329,17 @@ impl Tool for ComposioActionTool { reshape_args.as_ref(), &mut resp.data, ); + // A reshape the backend also renders is invisible unless the + // provider says its version supersedes: the body below + // prefers `markdownFormatted` and only falls back to the + // JSON envelope when it is absent. + if provider.reshape_supersedes_markdown(&self.action_name) { + tracing::debug!( + tool = %&self.action_name, + "[composio] provider reshape supersedes the backend markdown rendering" + ); + resp.markdown_formatted = None; + } } // Published after the reshape, matching `ComposioExecuteTool`. // The payload names no reshaped field today, so the order is not diff --git a/src/openhuman/integrations/composio/tools.rs b/src/openhuman/integrations/composio/tools.rs index 2eefce81e8..9bb07e95c5 100644 --- a/src/openhuman/integrations/composio/tools.rs +++ b/src/openhuman/integrations/composio/tools.rs @@ -1546,6 +1546,17 @@ impl Tool for ComposioExecuteTool { reshape_args.as_ref(), &mut resp.data, ); + // A reshape the backend also renders is invisible unless the + // provider says its version supersedes: the body below + // prefers `markdownFormatted` and only falls back to the + // JSON envelope when it is absent. + if provider.reshape_supersedes_markdown(&tool) { + tracing::debug!( + tool = %&tool, + "[composio] provider reshape supersedes the backend markdown rendering" + ); + resp.markdown_formatted = None; + } } tracing::info!( tool = %tool, diff --git a/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs b/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs index 1e3494ba89..c5276e1d35 100644 --- a/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs +++ b/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs @@ -65,11 +65,130 @@ pub fn post_process(slug: &str, arguments: Option<&Value>, data: &mut Value) { ); return; } - if slug == "GMAIL_FETCH_EMAILS" { - reshape_fetch_emails(data) + match slug { + "GMAIL_FETCH_EMAILS" => reshape_fetch_emails(data), + "GMAIL_LIST_THREADS" => reshape_list_threads(data), + _ => {} } } +/// Rewrite a `GMAIL_LIST_THREADS` `data` object in place so each thread carries +/// what a list is read for: who it is from, what it is about, and when. +/// +/// The upstream shape has neither of those at the top level. With `verbose` +/// off a thread is `{historyId, id, snippet}`; with it on the sender and +/// subject are buried in `messages[].payload.headers[]` alongside the full MIME +/// tree and ~40 `Received:` headers. Composio's own markdown rendering of this +/// action is a bare list of thread ids and nothing else, so an agent that ran a +/// search got back a page of hex strings and had to fetch every thread in full +/// just to learn which one it wanted. Observed live: a search over 20 threads +/// answered "not found" after opening the first four. +/// +/// So this lifts the headers out and drops `payload` entirely. `verbose` still +/// decides how much there is to lift, but the envelope is the same either way. +fn reshape_list_threads(data: &mut Value) { + let container = match data.get_mut("threads") { + Some(_) => data, + None => match data.get_mut("data").and_then(|v| v.as_object_mut()) { + Some(_) => data.get_mut("data").unwrap(), + None => return, + }, + }; + + let Some(obj) = container.as_object_mut() else { + return; + }; + + let raw_threads = obj + .remove("threads") + .and_then(|v| match v { + Value::Array(arr) => Some(arr), + _ => None, + }) + .unwrap_or_default(); + let next_page_token = obj.remove("nextPageToken").unwrap_or(Value::Null); + let result_size_estimate = obj.remove("resultSizeEstimate").unwrap_or(Value::Null); + + let threads: Vec = raw_threads.into_iter().map(reshape_thread).collect(); + tracing::debug!( + threads = threads.len(), + "[composio:gmail][post-process] GMAIL_LIST_THREADS reshaped" + ); + + let mut envelope = Map::new(); + envelope.insert("threads".into(), Value::Array(threads)); + if !next_page_token.is_null() { + envelope.insert("nextPageToken".into(), next_page_token); + } + if !result_size_estimate.is_null() { + envelope.insert("resultSizeEstimate".into(), result_size_estimate); + } + + *container = Value::Object(envelope); +} + +/// Map one raw thread to its slim counterpart. +/// +/// `subject` / `from` / `date` / `labels` come from the thread's newest message +/// (the one a list view is about), picked by `internalDate` because the action's +/// own contract states message order is not guaranteed. They are absent +/// entirely when the caller did not ask for `verbose`, since the upstream sends +/// no messages to read them from — `snippet` is then the only content there is. +fn reshape_thread(raw: Value) -> Value { + let Value::Object(obj) = raw else { + return raw; + }; + + let mut out = Map::new(); + out.insert("id".into(), obj.get("id").cloned().unwrap_or(Value::Null)); + + let messages = obj.get("messages").and_then(|v| v.as_array()); + let newest = messages.and_then(|arr| { + arr.iter().filter_map(|m| m.as_object()).max_by_key(|m| { + m.get("internalDate") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }) + }); + + if let Some(msg) = newest { + if let Some(subject) = pick_header(msg, "Subject") { + out.insert("subject".into(), subject); + } + if let Some(from) = pick_header(msg, "From") { + out.insert("from".into(), from); + } + let date = pick_header(msg, "Date").unwrap_or(Value::Null); + if !date.is_null() { + if let Some(local) = date.as_str().and_then(format_email_local_time) { + out.insert("date_local".into(), Value::String(local)); + } + out.insert("date".into(), date); + } + if let Some(labels) = msg.get("labelIds").cloned() { + out.insert("labels".into(), labels); + } + } + + // The thread-level snippet is what `verbose` off gives; a verbose response + // carries it per message instead, so fall back to the newest message's. + let snippet = obj + .get("snippet") + .cloned() + .or_else(|| newest.and_then(|m| m.get("snippet").cloned())) + .unwrap_or(Value::Null); + if !snippet.is_null() { + out.insert("snippet".into(), snippet); + } + + if let Some(count) = messages.map(|m| m.len()) { + out.insert("messageCount".into(), Value::from(count)); + } + + Value::Object(out) +} + /// Stash per-message slices of the response-level `markdownFormatted` /// onto the corresponding entries inside `data.messages[]`. /// diff --git a/src/openhuman/memory/sync/composio/providers/gmail/provider.rs b/src/openhuman/memory/sync/composio/providers/gmail/provider.rs index 788689525e..9f82032fea 100644 --- a/src/openhuman/memory/sync/composio/providers/gmail/provider.rs +++ b/src/openhuman/memory/sync/composio/providers/gmail/provider.rs @@ -80,6 +80,29 @@ impl ComposioProvider for GmailProvider { super::post_process::post_process(slug, arguments, data); } + /// Only `GMAIL_LIST_THREADS`, and only because its rendering throws away + /// the answer. + /// + /// The backend renders a thread list as bare ids — captured verbatim: + /// + /// ```text + /// **Gmail Threads** — 2 returned + /// - `19fbd08f6a3e635b` + /// - `19fbc77215064e91` + /// ``` + /// + /// The payload behind that list carries each thread's subject, sender, + /// date, labels, and snippet, and `reshape_list_threads` lifts them. Live, + /// a sub-agent searching for a mail that does exist got the ids, had + /// nothing to recognise the thread by, and reported it could not be found. + /// + /// `GMAIL_FETCH_EMAILS` is deliberately absent: its reshape *reads* + /// `markdownFormatted` for the message body (`extract_markdown_body`), so + /// clearing it there would throw away the body rather than reveal it. + fn reshape_supersedes_markdown(&self, slug: &str) -> bool { + slug.eq_ignore_ascii_case("GMAIL_LIST_THREADS") + } + async fn fetch_user_profile( &self, ctx: &ProviderContext, @@ -188,3 +211,29 @@ impl ComposioProvider for GmailProvider { // the sole consumer; the `sync_depth_days` date floor (`epoch_floor_from_depth`) // stays in `super::super::helpers` because `gmail::source` builds an // `after:` filter from it. + +#[cfg(test)] +mod supersede_tests { + use super::*; + use crate::openhuman::memory_sync::composio::providers::ComposioProvider; + + /// The rendering for a thread list is a bare id list, so the reshape — which + /// lifts the subject, sender, date, and snippet the same payload carries — + /// has to replace it or the model never sees any of them. + #[test] + fn the_thread_list_reshape_supersedes_the_backend_rendering() { + assert!(GmailProvider.reshape_supersedes_markdown("GMAIL_LIST_THREADS")); + // The model's casing varies; the action does not. + assert!(GmailProvider.reshape_supersedes_markdown("gmail_list_threads")); + } + + /// The failure case that matters. `reshape_fetch_emails` READS + /// `markdownFormatted` for the message body, so clearing it here would + /// throw the body away instead of revealing it. + #[test] + fn the_message_fetch_reshape_does_not() { + assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_FETCH_EMAILS")); + assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_FETCH_MESSAGE_BY_THREAD_ID")); + assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_SEND_EMAIL")); + } +} diff --git a/src/openhuman/memory/sync/composio/providers/traits.rs b/src/openhuman/memory/sync/composio/providers/traits.rs index 9f4bf30393..2a3bed4f2a 100644 --- a/src/openhuman/memory/sync/composio/providers/traits.rs +++ b/src/openhuman/memory/sync/composio/providers/traits.rs @@ -234,6 +234,31 @@ pub trait ComposioProvider: Send + Sync { let _ = (slug, arguments, data); } + /// Whether [`Self::post_process_action_result`]'s rewrite of `data` + /// **replaces** the backend's `markdownFormatted` rendering for `slug`. + /// + /// Both Composio dispatch paths — `ComposioActionTool` and the + /// `composio_execute` dispatcher — build the model-facing body by + /// preferring `markdownFormatted` and falling back to the JSON envelope + /// only when it is absent. A reshape of an action the backend also renders + /// is therefore **invisible** to the model unless the provider says so + /// here; `data` is rewritten, and then nothing reads it. + /// + /// Answer `true` only where the reshape carries something the rendering + /// drops. It is deliberately per-action rather than per-toolkit, because + /// within one toolkit the answer differs: Gmail's `GMAIL_FETCH_EMAILS` + /// reshape *consumes* `markdownFormatted` (it is the message body, already + /// URL-shortened and footer-stripped), while `GMAIL_LIST_THREADS` is + /// rendered as a bare list of thread ids that discards the subjects, + /// senders, dates, and snippets the same payload carries. + /// + /// Default `false`: a provider that has not thought about it keeps the + /// existing behaviour. + fn reshape_supersedes_markdown(&self, slug: &str) -> bool { + let _ = slug; + false + } + /// Hook fired when a Composio trigger webhook arrives for this /// toolkit. `payload` is the raw provider payload as forwarded by /// the backend. Implementations should be defensive — payload From cb5dcc39b5e20f59a8872a944abd2a38f5857f98 Mon Sep 17 00:00:00 2001 From: yh928 Date: Wed, 5 Aug 2026 09:41:25 +0900 Subject: [PATCH 4/7] fix(composio): let the reshape and the supersede decision agree on the same call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the pair could disagree, and both handed the model the raw MIME tree — neither the backend summary nor the slim envelope. **Casing.** `post_process` dispatched on a case-sensitive match while `reshape_supersedes_markdown` folded case. `composio_execute` takes the action as an argument, so a lowercase `gmail_list_threads` reaches both verbatim: the dispatch fell to its no-op arm, the predicate still said "my version supersedes", and the rendering was cleared on behalf of a reshape that never ran. The action is the same action whatever the model capitalises; only one of the two may decide that, so the dispatch now folds case too. **`raw_html`.** The flag makes `post_process` return early and leave `data` untouched — that is its whole point, for `GMAIL_FETCH_EMAILS` where the caller wants the original body. A slug-only predicate could not see it, so the same clearing happened for a pass-through response. The answer is a property of the call rather than of the slug, so `reshape_supersedes_markdown` now takes the caller's arguments and answers `false` when the reshape was opted out of. Reported by greptile on #5323 (P1 + P2). --- .../integrations/composio/action_tool.rs | 4 +- src/openhuman/integrations/composio/tools.rs | 2 +- .../composio/providers/gmail/post_process.rs | 17 ++-- .../sync/composio/providers/gmail/provider.rs | 82 +++++++++++++++++-- .../memory/sync/composio/providers/traits.rs | 15 +++- 5 files changed, 104 insertions(+), 16 deletions(-) diff --git a/src/openhuman/integrations/composio/action_tool.rs b/src/openhuman/integrations/composio/action_tool.rs index cdc5460e1b..8e86dc6145 100644 --- a/src/openhuman/integrations/composio/action_tool.rs +++ b/src/openhuman/integrations/composio/action_tool.rs @@ -333,7 +333,9 @@ impl Tool for ComposioActionTool { // provider says its version supersedes: the body below // prefers `markdownFormatted` and only falls back to the // JSON envelope when it is absent. - if provider.reshape_supersedes_markdown(&self.action_name) { + if provider + .reshape_supersedes_markdown(&self.action_name, reshape_args.as_ref()) + { tracing::debug!( tool = %&self.action_name, "[composio] provider reshape supersedes the backend markdown rendering" diff --git a/src/openhuman/integrations/composio/tools.rs b/src/openhuman/integrations/composio/tools.rs index 9bb07e95c5..ae1c5059ea 100644 --- a/src/openhuman/integrations/composio/tools.rs +++ b/src/openhuman/integrations/composio/tools.rs @@ -1550,7 +1550,7 @@ impl Tool for ComposioExecuteTool { // provider says its version supersedes: the body below // prefers `markdownFormatted` and only falls back to the // JSON envelope when it is absent. - if provider.reshape_supersedes_markdown(&tool) { + if provider.reshape_supersedes_markdown(&tool, reshape_args.as_ref()) { tracing::debug!( tool = %&tool, "[composio] provider reshape supersedes the backend markdown rendering" diff --git a/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs b/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs index c5276e1d35..fc432987af 100644 --- a/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs +++ b/src/openhuman/memory/sync/composio/providers/gmail/post_process.rs @@ -65,10 +65,17 @@ pub fn post_process(slug: &str, arguments: Option<&Value>, data: &mut Value) { ); return; } - match slug { - "GMAIL_FETCH_EMAILS" => reshape_fetch_emails(data), - "GMAIL_LIST_THREADS" => reshape_list_threads(data), - _ => {} + // Case-insensitive, because the slug reaching here is whatever the model + // wrote. `composio_execute` takes the action as an argument, so a lowercase + // `gmail_list_threads` arrives verbatim; a case-sensitive match dropped it + // to the no-op arm while `reshape_supersedes_markdown` — which folds case — + // still cleared the backend rendering, handing the model the raw MIME tree + // with neither the reshape nor the markdown. The action is the same action + // whatever the model capitalises; only one of the two may decide that. + if slug.eq_ignore_ascii_case("GMAIL_FETCH_EMAILS") { + reshape_fetch_emails(data); + } else if slug.eq_ignore_ascii_case("GMAIL_LIST_THREADS") { + reshape_list_threads(data); } } @@ -379,7 +386,7 @@ fn validate_segments_against_hints(segments: &[String], hints: &[Value]) -> bool /// Returns true when the caller explicitly set `raw_html: true` (or the /// camelCase `rawHtml: true`) in the `arguments` object. -fn is_raw_html_flag_set(arguments: Option<&Value>) -> bool { +pub(super) fn is_raw_html_flag_set(arguments: Option<&Value>) -> bool { let Some(obj) = arguments.and_then(|v| v.as_object()) else { return false; }; diff --git a/src/openhuman/memory/sync/composio/providers/gmail/provider.rs b/src/openhuman/memory/sync/composio/providers/gmail/provider.rs index 9f82032fea..c34e701202 100644 --- a/src/openhuman/memory/sync/composio/providers/gmail/provider.rs +++ b/src/openhuman/memory/sync/composio/providers/gmail/provider.rs @@ -99,8 +99,14 @@ impl ComposioProvider for GmailProvider { /// `GMAIL_FETCH_EMAILS` is deliberately absent: its reshape *reads* /// `markdownFormatted` for the message body (`extract_markdown_body`), so /// clearing it there would throw away the body rather than reveal it. - fn reshape_supersedes_markdown(&self, slug: &str) -> bool { + fn reshape_supersedes_markdown(&self, slug: &str, arguments: Option<&Value>) -> bool { + // `raw_html` makes `post_process` pass the response through untouched, + // so there is no reshape to supersede anything. Clearing the rendering + // anyway left the model with neither the backend summary nor the slim + // envelope — just the raw MIME tree, which is the opposite of what the + // flag is for. slug.eq_ignore_ascii_case("GMAIL_LIST_THREADS") + && !super::post_process::is_raw_html_flag_set(arguments) } async fn fetch_user_profile( @@ -215,16 +221,16 @@ impl ComposioProvider for GmailProvider { #[cfg(test)] mod supersede_tests { use super::*; - use crate::openhuman::memory_sync::composio::providers::ComposioProvider; + use crate::openhuman::memory::sync::composio::providers::ComposioProvider; /// The rendering for a thread list is a bare id list, so the reshape — which /// lifts the subject, sender, date, and snippet the same payload carries — /// has to replace it or the model never sees any of them. #[test] fn the_thread_list_reshape_supersedes_the_backend_rendering() { - assert!(GmailProvider.reshape_supersedes_markdown("GMAIL_LIST_THREADS")); + assert!(GmailProvider.reshape_supersedes_markdown("GMAIL_LIST_THREADS", None)); // The model's casing varies; the action does not. - assert!(GmailProvider.reshape_supersedes_markdown("gmail_list_threads")); + assert!(GmailProvider.reshape_supersedes_markdown("gmail_list_threads", None)); } /// The failure case that matters. `reshape_fetch_emails` READS @@ -232,8 +238,70 @@ mod supersede_tests { /// throw the body away instead of revealing it. #[test] fn the_message_fetch_reshape_does_not() { - assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_FETCH_EMAILS")); - assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_FETCH_MESSAGE_BY_THREAD_ID")); - assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_SEND_EMAIL")); + assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_FETCH_EMAILS", None)); + assert!( + !GmailProvider.reshape_supersedes_markdown("GMAIL_FETCH_MESSAGE_BY_THREAD_ID", None) + ); + assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_SEND_EMAIL", None)); + } + + /// `raw_html` makes `post_process` pass the response through untouched, so + /// there is no reshape to supersede. Answering `true` anyway cleared the + /// rendering on behalf of a reshape that never ran, and the model was handed + /// the raw MIME tree with neither the backend summary nor the slim envelope. + #[test] + fn a_raw_html_call_supersedes_nothing() { + let raw = json!({ "raw_html": true }); + assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_LIST_THREADS", Some(&raw))); + // The camelCase spelling the model also sends. + let camel = json!({ "rawHtml": true }); + assert!(!GmailProvider.reshape_supersedes_markdown("GMAIL_LIST_THREADS", Some(&camel))); + // Explicitly off is the ordinary call, and still supersedes. + let off = json!({ "raw_html": false }); + assert!(GmailProvider.reshape_supersedes_markdown("GMAIL_LIST_THREADS", Some(&off))); + } +} + +#[cfg(test)] +mod dispatch_case_tests { + use super::*; + use crate::openhuman::memory::sync::composio::providers::ComposioProvider; + + /// The slug reaching `post_process` is whatever the model wrote — + /// `composio_execute` takes the action as an argument, so a lowercase spelling + /// arrives verbatim. A case-sensitive dispatch fell through to the no-op arm + /// while `reshape_supersedes_markdown` folded case and still cleared the + /// backend rendering: the model got the raw MIME tree and nothing else. + #[test] + fn a_lowercase_slug_is_reshaped_like_its_uppercase_spelling() { + let payload = || { + json!({ + "threads": [{ + "id": "abc", + "snippet": "hello", + "messages": [{ + "payload": { "headers": [ + { "name": "Subject", "value": "Quarterly report" }, + { "name": "From", "value": "a@example.com" } + ]} + }] + }] + }) + }; + + let mut upper = payload(); + GmailProvider.post_process_action_result("GMAIL_LIST_THREADS", None, &mut upper); + let mut lower = payload(); + GmailProvider.post_process_action_result("gmail_list_threads", None, &mut lower); + + assert_eq!( + upper, lower, + "the action is the same action whatever the model capitalises" + ); + // And the reshape actually ran, so the assertion above is not two no-ops. + assert!( + upper.to_string().contains("Quarterly report"), + "the reshape lifts the subject out of the MIME tree: {upper}" + ); } } diff --git a/src/openhuman/memory/sync/composio/providers/traits.rs b/src/openhuman/memory/sync/composio/providers/traits.rs index 2a3bed4f2a..6b58a5a7d5 100644 --- a/src/openhuman/memory/sync/composio/providers/traits.rs +++ b/src/openhuman/memory/sync/composio/providers/traits.rs @@ -252,10 +252,21 @@ pub trait ComposioProvider: Send + Sync { /// rendered as a bare list of thread ids that discards the subjects, /// senders, dates, and snippets the same payload carries. /// + /// `arguments` is the caller's original argument object, because the answer + /// is a property of the **call**, not of the slug alone: the same action can + /// be invoked in a way that skips the reshape entirely. Gmail's `raw_html` + /// opt-out is the live example — `post_process` returns early and leaves + /// `data` untouched, so a slug-only `true` cleared the rendering on behalf + /// of a reshape that never ran and the model got neither. + /// /// Default `false`: a provider that has not thought about it keeps the /// existing behaviour. - fn reshape_supersedes_markdown(&self, slug: &str) -> bool { - let _ = slug; + fn reshape_supersedes_markdown( + &self, + slug: &str, + arguments: Option<&serde_json::Value>, + ) -> bool { + let _ = (slug, arguments); false } From 0f5b7da7c021a2434b57138b337146a3598f0960 Mon Sep 17 00:00:00 2001 From: yh928 Date: Wed, 5 Aug 2026 12:15:19 +0900 Subject: [PATCH 5/7] fix(composio): do not reshape a failed action response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both execute paths reshaped every `Ok(resp)` before looking at `resp.successful`. A failure carries the provider's diagnostics in `data`, so a reshaper written against the success shape rewrote them into an empty or wrong-shaped record — and `reshape_supersedes_markdown` then cleared the backend's error rendering on behalf of a reshape that had found nothing. The model was left with neither the error nor the diagnostics, which is the same class of loss this PR opened to fix, on the other branch. The rule is named rather than repeated: `provider_for_reshape(slug, successful)` is the one place that says a reshape needs both a registered provider and a response worth reshaping, and both `ComposioExecuteTool` and `ComposioActionTool` now ask it. Repeating the `resp.successful` check inline would leave the third call site to remember it, which is how this one was missed. Tests: a failed `GMAIL_LIST_THREADS` selects no provider while the successful one still does (asserted, so the test cannot pass vacuously), and an unknown slug selects none either way. providers 307, composio::tools 104 pass. Reported by CodeRabbit on #5323. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SRSNnqQsokuGmkbpLoLCGy --- .../integrations/composio/action_tool.rs | 10 ++++- src/openhuman/integrations/composio/tools.rs | 10 ++++- .../memory/sync/composio/providers/mod.rs | 3 +- .../sync/composio/providers/registry.rs | 45 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/openhuman/integrations/composio/action_tool.rs b/src/openhuman/integrations/composio/action_tool.rs index 8e86dc6145..b937fa943c 100644 --- a/src/openhuman/integrations/composio/action_tool.rs +++ b/src/openhuman/integrations/composio/action_tool.rs @@ -321,8 +321,14 @@ impl Tool for ComposioActionTool { // per-action Gmail fetch doesn't drop a full MIME tree into the // agent's context. Only the raw-JSON fallback body serializes // `resp.data`; a backend-rendered markdown body is unaffected. - if let Some(provider) = super::providers::toolkit_from_slug(&self.action_name) - .and_then(|tk| super::providers::get_provider(&tk)) + // Only a successful response is reshaped. A failure carries the + // provider's diagnostics in `data`, which a reshaper written for + // the success shape rewrites into an empty or wrong-shaped + // record, and `reshape_supersedes_markdown` would then clear the + // backend's error rendering on behalf of a reshape that found + // nothing — leaving the model neither. + if let Some(provider) = + super::providers::provider_for_reshape(&self.action_name, resp.successful) { provider.post_process_action_result( &self.action_name, diff --git a/src/openhuman/integrations/composio/tools.rs b/src/openhuman/integrations/composio/tools.rs index ae1c5059ea..89fe2cd5bc 100644 --- a/src/openhuman/integrations/composio/tools.rs +++ b/src/openhuman/integrations/composio/tools.rs @@ -42,7 +42,7 @@ use crate::openhuman::tools::traits::{ use super::client::{create_composio_client, direct_list_connections, ComposioClientKind}; use super::providers::{ catalog_for_toolkit, classify_unknown, find_curated, get_provider, load_user_scope_or_default, - toolkit_from_slug, ToolScope, UserScopePref, + provider_for_reshape, toolkit_from_slug, ToolScope, UserScopePref, }; use super::types::ComposioToolsResponse; @@ -1540,7 +1540,13 @@ impl Tool for ComposioExecuteTool { // already clean and unaffected. The sync path applies the same // reshape via `ReshapingExecutor`; here we run it inline on the // agent's direct call. - if let Some(provider) = toolkit_from_slug(&tool).and_then(|tk| get_provider(&tk)) { + // Only a successful response is reshaped. A failure carries the + // provider's diagnostics in `data`, which a reshaper written for + // the success shape rewrites into an empty or wrong-shaped + // record, and `reshape_supersedes_markdown` would then clear the + // backend's error rendering on behalf of a reshape that found + // nothing — leaving the model neither. + if let Some(provider) = provider_for_reshape(&tool, resp.successful) { provider.post_process_action_result( &tool, reshape_args.as_ref(), diff --git a/src/openhuman/memory/sync/composio/providers/mod.rs b/src/openhuman/memory/sync/composio/providers/mod.rs index cefe9ae178..19bc569fed 100644 --- a/src/openhuman/memory/sync/composio/providers/mod.rs +++ b/src/openhuman/memory/sync/composio/providers/mod.rs @@ -278,7 +278,8 @@ pub fn agent_ready_toolkits() -> Vec<&'static str> { pub use descriptions::toolkit_description; pub(crate) use helpers::{first_array_str, merge_extra, pick_str}; pub use registry::{ - all_providers, get_provider, init_default_providers, register_provider, ProviderArc, + all_providers, get_provider, init_default_providers, provider_for_reshape, register_provider, + ProviderArc, }; pub use scope_lookup::{curated_scope_for, toolkit_has_scope}; pub use tool_scope::{classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope}; diff --git a/src/openhuman/memory/sync/composio/providers/registry.rs b/src/openhuman/memory/sync/composio/providers/registry.rs index 3149c289f9..4ad8017b2e 100644 --- a/src/openhuman/memory/sync/composio/providers/registry.rs +++ b/src/openhuman/memory/sync/composio/providers/registry.rs @@ -53,6 +53,28 @@ pub fn register_provider(provider: ProviderArc) { } /// Look up the provider for a toolkit slug, if one is registered. +/// The provider that should reshape an action response, or `None`. +/// +/// Two conditions, and the second is the one that is easy to forget at a call +/// site: the slug must map to a registered provider, **and** the response must +/// have succeeded. [`ComposioProvider::post_process_action_result`] and +/// [`ComposioProvider::reshape_supersedes_markdown`] are both written against +/// the success shape. A failure carries the provider's diagnostics in `data` +/// instead, so a reshaper run on it rewrites them into an empty or wrong-shaped +/// record — and `reshape_supersedes_markdown` then clears the backend's error +/// rendering on behalf of a reshape that found nothing, leaving the model with +/// neither the error nor the diagnostics. +/// +/// Named rather than inlined so both execute paths (`ComposioExecuteTool` and +/// `ComposioActionTool`) state the same rule, and so the rule is testable +/// without a live client. +pub fn provider_for_reshape(slug: &str, successful: bool) -> Option { + if !successful { + return None; + } + super::toolkit_from_slug(slug).and_then(|toolkit| get_provider(&toolkit)) +} + pub fn get_provider(toolkit: &str) -> Option { let key = toolkit.trim(); if key.is_empty() { @@ -150,4 +172,27 @@ mod tests { register_provider(Arc::new(DummyProvider { slug: "" })); assert!(get_provider("").is_none()); } + + /// The regression for the failure path. A `GMAIL_LIST_THREADS` that failed + /// carries the provider's diagnostics in `data`; reshaping it rewrote them + /// into the success shape and then cleared the backend's error rendering on + /// behalf of a reshape that had found nothing, so the model got neither. + #[test] + fn a_failed_response_selects_no_provider_to_reshape_with() { + register_provider(Arc::new(DummyProvider { slug: "gmail" })); + + assert!( + provider_for_reshape("GMAIL_LIST_THREADS", true).is_some(), + "the success case must still reshape, or this test proves nothing" + ); + assert!(provider_for_reshape("GMAIL_LIST_THREADS", false).is_none()); + } + + /// An unregistered toolkit has nothing to reshape with either way — the + /// success flag does not manufacture a provider. + #[test] + fn an_unknown_slug_selects_no_provider_even_on_success() { + assert!(provider_for_reshape("NOT_A_REAL_ACTION", true).is_none()); + assert!(provider_for_reshape("", true).is_none()); + } } From 7af449d8497cf8aa51a8413c58202a67768c071d Mon Sep 17 00:00:00 2001 From: yh928 Date: Wed, 5 Aug 2026 12:34:49 +0900 Subject: [PATCH 6/7] test(composio): keep the reshape probe out of the real gmail registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider registry is process-global, so registering a `DummyProvider` under the `gmail` slug hands it to any parallel test that looks Gmail up — the RwLock stops the data race, not the semantic one. Uses a `reshapeprobe` toolkit of the test's own instead; `toolkit_from_slug` splits on the first `_`, so `RESHAPEPROBE_LIST_THINGS` resolves to it and nothing real is displaced. providers::registry 6 pass. Reported by CodeRabbit on #5323. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SRSNnqQsokuGmkbpLoLCGy --- .../memory/sync/composio/providers/registry.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/openhuman/memory/sync/composio/providers/registry.rs b/src/openhuman/memory/sync/composio/providers/registry.rs index 4ad8017b2e..c5f366d487 100644 --- a/src/openhuman/memory/sync/composio/providers/registry.rs +++ b/src/openhuman/memory/sync/composio/providers/registry.rs @@ -179,13 +179,19 @@ mod tests { /// behalf of a reshape that had found nothing, so the model got neither. #[test] fn a_failed_response_selects_no_provider_to_reshape_with() { - register_provider(Arc::new(DummyProvider { slug: "gmail" })); + // A toolkit of this test's own, not `gmail`: the registry is process- + // global, so registering a `DummyProvider` under a real slug would hand + // it to any parallel test that looks Gmail up. `toolkit_from_slug` + // splits on the first `_`, so the slug below resolves to this toolkit. + register_provider(Arc::new(DummyProvider { + slug: "reshapeprobe", + })); assert!( - provider_for_reshape("GMAIL_LIST_THREADS", true).is_some(), + provider_for_reshape("RESHAPEPROBE_LIST_THINGS", true).is_some(), "the success case must still reshape, or this test proves nothing" ); - assert!(provider_for_reshape("GMAIL_LIST_THREADS", false).is_none()); + assert!(provider_for_reshape("RESHAPEPROBE_LIST_THINGS", false).is_none()); } /// An unregistered toolkit has nothing to reshape with either way — the From ead25b6c41b123db508a9507c5bbf1ea0bc6b46b Mon Sep 17 00:00:00 2001 From: yh928 Date: Wed, 5 Aug 2026 16:24:16 +0900 Subject: [PATCH 7/7] fix(composio): log the approval and reshape-selection branches Both new domain branches decided something and said nothing. Adds a debug event for the per-action approval classification (`external_effect`, i.e. whether this slug routes through the gate) and one for reshape-provider selection (`successful`, the condition that was silently wrong before), and puts the existing supersede log on the `composio` target so all three carry the tool slug as their correlation field. integrations::composio tests pass. Reported by CodeRabbit on #5323. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SRSNnqQsokuGmkbpLoLCGy --- .../integrations/composio/action_tool.rs | 16 +++++++++++++++- src/openhuman/integrations/composio/tools.rs | 7 +++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/openhuman/integrations/composio/action_tool.rs b/src/openhuman/integrations/composio/action_tool.rs index b937fa943c..c17bec83c6 100644 --- a/src/openhuman/integrations/composio/action_tool.rs +++ b/src/openhuman/integrations/composio/action_tool.rs @@ -155,7 +155,14 @@ impl Tool for ComposioActionTool { // slug) routes through the `ApprovalGate` before it runs; a pure read // flows through unprompted. Without this the model could send mail / // create records via a per-action tool with no approval prompt at all. - super::tools::action_mutates_external_state(&self.action_name) + let mutates = super::tools::action_mutates_external_state(&self.action_name); + tracing::debug!( + target: "composio", + tool = %self.action_name, + external_effect = mutates, + "[composio] per-action approval classification" + ); + mutates } fn category(&self) -> ToolCategory { @@ -327,6 +334,12 @@ impl Tool for ComposioActionTool { // record, and `reshape_supersedes_markdown` would then clear the // backend's error rendering on behalf of a reshape that found // nothing — leaving the model neither. + tracing::debug!( + target: "composio", + tool = %self.action_name, + successful = resp.successful, + "[composio] reshape provider selection" + ); if let Some(provider) = super::providers::provider_for_reshape(&self.action_name, resp.successful) { @@ -343,6 +356,7 @@ impl Tool for ComposioActionTool { .reshape_supersedes_markdown(&self.action_name, reshape_args.as_ref()) { tracing::debug!( + target: "composio", tool = %&self.action_name, "[composio] provider reshape supersedes the backend markdown rendering" ); diff --git a/src/openhuman/integrations/composio/tools.rs b/src/openhuman/integrations/composio/tools.rs index 89fe2cd5bc..8d986feb6b 100644 --- a/src/openhuman/integrations/composio/tools.rs +++ b/src/openhuman/integrations/composio/tools.rs @@ -1546,6 +1546,12 @@ impl Tool for ComposioExecuteTool { // record, and `reshape_supersedes_markdown` would then clear the // backend's error rendering on behalf of a reshape that found // nothing — leaving the model neither. + tracing::debug!( + target: "composio", + tool = %tool, + successful = resp.successful, + "[composio] reshape provider selection" + ); if let Some(provider) = provider_for_reshape(&tool, resp.successful) { provider.post_process_action_result( &tool, @@ -1558,6 +1564,7 @@ impl Tool for ComposioExecuteTool { // JSON envelope when it is absent. if provider.reshape_supersedes_markdown(&tool, reshape_args.as_ref()) { tracing::debug!( + target: "composio", tool = %&tool, "[composio] provider reshape supersedes the backend markdown rendering" );