From 7ce2d5c8eed06a502a622a5a791a6d2d743121c4 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Fri, 15 May 2026 23:37:14 +0530 Subject: [PATCH 1/5] fix(socket): panic-safe UTF-8 truncation in event payload debug log (#1814) Bind payload JSON once and slice via floor_char_boundary so multi-byte characters straddling byte 500 no longer abort the core thread (OPENHUMAN-TAURI-KC). Co-authored-by: Cursor --- src/openhuman/socket/event_handlers.rs | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/openhuman/socket/event_handlers.rs b/src/openhuman/socket/event_handlers.rs index e2833b8f47..ec100449f3 100644 --- a/src/openhuman/socket/event_handlers.rs +++ b/src/openhuman/socket/event_handlers.rs @@ -32,10 +32,12 @@ pub(super) fn handle_sio_event( event_name, data.to_string().len() ); + let payload_json = data.to_string(); + let preview_end = crate::openhuman::util::floor_char_boundary(&payload_json, 500); log::debug!( "[socket] event payload: name={} data={}", event_name, - &data.to_string()[..data.to_string().len().min(500)] + &payload_json[..preview_end] ); match event_name { @@ -329,6 +331,33 @@ mod tests { assert_eq!(*shared.status.read(), ConnectionStatus::Error); } + #[test] + fn handle_sio_event_debug_truncation_respects_utf8_boundary() { + // Serialized JSON must be >= 500 bytes with a multi-byte codepoint + // straddling byte 500 — mirrors OPENHUMAN-TAURI-KC (Cyrillic at 499..501). + let inner = format!("{}н", "a".repeat(498)); + let payload_json = serde_json::Value::String(inner.clone()).to_string(); + assert!( + payload_json.len() >= 500, + "fixture too short: {} bytes", + payload_json.len() + ); + assert!( + !payload_json.is_char_boundary(500), + "fixture must place byte 500 inside a multi-byte character" + ); + + let shared = make_shared(); + let (tx, _rx) = mpsc::unbounded_channel::(); + handle_sio_event( + "weird.unrelated.event", + serde_json::Value::String(inner), + &tx, + &shared, + ); + assert_eq!(*shared.status.read(), ConnectionStatus::Disconnected); + } + #[test] fn handle_sio_event_unknown_event_is_noop_on_status() { let shared = make_shared(); From 711e27e8c9badd99f47faed40a9e597f8347b378 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Fri, 15 May 2026 23:50:38 +0530 Subject: [PATCH 2/5] composio: preserve tool error semantics instead of bucketing as 502 (#1797) Validate and normalize high-traffic Composio actions before dispatch, classify provider failures with grep-friendly error classes, and retry Slack history rate limits so agents see actionable messages instead of generic gateway errors. Co-authored-by: Cursor --- app/src/lib/composio/formatters.test.ts | 15 +- app/src/lib/composio/formatters.ts | 23 ++ src/openhuman/composio/action_tool.rs | 11 +- src/openhuman/composio/client.rs | 24 +- src/openhuman/composio/error_mapping.rs | 212 ++++++++++++++++++ src/openhuman/composio/error_mapping_tests.rs | 44 ++++ src/openhuman/composio/execute_dispatch.rs | 113 ++++++++++ .../composio/execute_dispatch_tests.rs | 60 +++++ src/openhuman/composio/execute_prepare.rs | 160 +++++++++++++ .../composio/execute_prepare_tests.rs | 41 ++++ src/openhuman/composio/mod.rs | 3 + src/openhuman/composio/ops.rs | 4 +- src/openhuman/composio/tools.rs | 7 +- 13 files changed, 702 insertions(+), 15 deletions(-) create mode 100644 src/openhuman/composio/error_mapping.rs create mode 100644 src/openhuman/composio/error_mapping_tests.rs create mode 100644 src/openhuman/composio/execute_dispatch.rs create mode 100644 src/openhuman/composio/execute_dispatch_tests.rs create mode 100644 src/openhuman/composio/execute_prepare.rs create mode 100644 src/openhuman/composio/execute_prepare_tests.rs diff --git a/app/src/lib/composio/formatters.test.ts b/app/src/lib/composio/formatters.test.ts index 947b42485e..f0991440c0 100644 --- a/app/src/lib/composio/formatters.test.ts +++ b/app/src/lib/composio/formatters.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { formatTriggerLabel } from './formatters'; +import { formatComposioToolError, formatTriggerLabel } from './formatters'; + +describe('formatComposioToolError', () => { + it('strips the classified prefix and returns the body', () => { + const raw = + '[composio:error:insufficient_scope] `GMAIL_FETCH_EMAILS` was rejected because the connected gmail account is missing required permissions.'; + expect(formatComposioToolError(raw)).toContain('missing required permissions'); + expect(formatComposioToolError(raw)).not.toContain('[composio:error:'); + }); + + it('passes through unclassified messages', () => { + expect(formatComposioToolError('plain failure')).toBe('plain failure'); + }); +}); describe('formatTriggerLabel', () => { it('formats GOOGLECALENDAR_GOOGLE_CALENDAR_EVENT_CREATED_TRIGGER correctly', () => { diff --git a/app/src/lib/composio/formatters.ts b/app/src/lib/composio/formatters.ts index 54dd78afcd..b19e91a88f 100644 --- a/app/src/lib/composio/formatters.ts +++ b/app/src/lib/composio/formatters.ts @@ -11,6 +11,29 @@ * 4. dedupe leading provider prefix when it reappears * 5. split on _, title-case each token, join with space */ +/** + * Parse a classified Composio error (`[composio:error:] …`) for UI copy. + */ +export function formatComposioToolError(raw: string | null | undefined): string { + if (!raw) return ''; + const match = /^\[composio:error:([a-z_]+)\]\s*(.*)$/is.exec(raw.trim()); + if (!match) return raw.trim(); + + const [, className, body] = match; + switch (className) { + case 'validation': + return body || 'Invalid tool arguments.'; + case 'insufficient_scope': + return body || 'Reconnect this integration and grant the requested permissions.'; + case 'rate_limited': + return body || 'The upstream service is rate-limiting requests. Try again shortly.'; + case 'gateway': + return body || 'Temporary connection issue. Try again in a moment.'; + default: + return body || raw.trim(); + } +} + export function formatTriggerLabel( slug: string | null | undefined, opts?: { overrides?: Record } diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index e3e6947b58..0f78c9c513 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -117,9 +117,12 @@ impl Tool for ComposioActionTool { } let started = std::time::Instant::now(); - let res = - super::auth_retry::execute_with_auth_retry(&self.client, &self.action_name, Some(args)) - .await; + let res = super::execute_dispatch::execute_composio_action( + &self.client, + &self.action_name, + Some(args), + ) + .await; let elapsed_ms = started.elapsed().as_millis() as u64; match res { @@ -165,7 +168,7 @@ impl Tool for ComposioActionTool { elapsed_ms, }, ); - Ok(ToolResult::error(format!("{}: {e}", self.action_name))) + Ok(ToolResult::error(e)) } } } diff --git a/src/openhuman/composio/client.rs b/src/openhuman/composio/client.rs index d3cd100f1a..7a9bc6ef1b 100644 --- a/src/openhuman/composio/client.rs +++ b/src/openhuman/composio/client.rs @@ -162,7 +162,8 @@ impl ComposioClient { if tool.is_empty() { anyhow::bail!("composio.execute_tool_once: tool slug must not be empty"); } - let arguments = arguments.unwrap_or(serde_json::Value::Object(Default::default())); + let arguments = super::execute_prepare::prepare_execute_arguments(tool, arguments) + .map_err(anyhow::Error::msg)?; tracing::debug!(tool = %tool, "[composio] execute_tool_once (no built-in retry)"); let body = json!({ "tool": tool, "arguments": arguments }); let result = self.post_execute_tool(&body).await; @@ -179,7 +180,12 @@ impl ComposioClient { "[composio] execute_tool_once failed" ), } - result + result.map_err(|e| { + anyhow::Error::msg(super::error_mapping::remap_transport_error( + tool, + &e.to_string(), + )) + }) } /// `POST /agent-integrations/composio/execute` — run a Composio @@ -193,11 +199,19 @@ impl ComposioClient { if tool.is_empty() { anyhow::bail!("composio.execute_tool: tool slug must not be empty"); } - let arguments = arguments.unwrap_or(serde_json::Value::Object(Default::default())); + let arguments = super::execute_prepare::prepare_execute_arguments(tool, arguments) + .map_err(anyhow::Error::msg)?; tracing::debug!(tool = %tool, "[composio] execute_tool"); let body = json!({ "tool": tool, "arguments": arguments }); - self.execute_tool_with_post_oauth_retry(tool, &body, POST_OAUTH_ACTION_RETRY_DELAY) - .await + let mut resp = self + .execute_tool_with_post_oauth_retry(tool, &body, POST_OAUTH_ACTION_RETRY_DELAY) + .await?; + if !resp.successful { + if let Some(ref err) = resp.error { + resp.error = Some(super::error_mapping::format_provider_error(tool, err)); + } + } + Ok(resp) } async fn execute_tool_with_post_oauth_retry( diff --git a/src/openhuman/composio/error_mapping.rs b/src/openhuman/composio/error_mapping.rs new file mode 100644 index 0000000000..804c7721c4 --- /dev/null +++ b/src/openhuman/composio/error_mapping.rs @@ -0,0 +1,212 @@ +//! Classify and format Composio tool failures so validation, scope, and +//! upstream-provider errors are not surfaced as generic gateway (502) failures. +//! +//! Issue #1797 — Composio support found tool-level failures on their side while +//! OpenHuman was bucketing them as HTTP 502 / gateway instability. + +/// Stable, grep-friendly error classes for metrics and UI routing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComposioErrorClass { + Validation, + InsufficientScope, + RateLimited, + UpstreamProvider, + ComposioPlatform, + Gateway, + Other, +} + +impl ComposioErrorClass { + pub fn as_str(self) -> &'static str { + match self { + Self::Validation => "validation", + Self::InsufficientScope => "insufficient_scope", + Self::RateLimited => "rate_limited", + Self::UpstreamProvider => "upstream_provider", + Self::ComposioPlatform => "composio_platform", + Self::Gateway => "gateway", + Self::Other => "other", + } + } +} + +pub fn classify_composio_error(tool: &str, message: &str) -> ComposioErrorClass { + let lower = message.to_ascii_lowercase(); + if is_validation_shape(&lower) { + return ComposioErrorClass::Validation; + } + if is_insufficient_scope_shape(&lower) { + return ComposioErrorClass::InsufficientScope; + } + if is_rate_limited_shape(&lower) { + return ComposioErrorClass::RateLimited; + } + if is_gateway_transport_shape(&lower) && !is_embedded_provider_failure(&lower) { + return ComposioErrorClass::Gateway; + } + if is_composio_platform_shape(&lower) { + return ComposioErrorClass::ComposioPlatform; + } + if tool.starts_with("GMAIL_") + || tool.starts_with("SLACK_") + || tool.starts_with("NOTION_") + || tool.starts_with("GOOGLECALENDAR_") + { + return ComposioErrorClass::UpstreamProvider; + } + ComposioErrorClass::Other +} + +pub fn format_provider_error(tool: &str, raw: &str) -> String { + let class = classify_composio_error(tool, raw); + let detail = raw.trim(); + let body = match class { + ComposioErrorClass::Validation => format!("Invalid arguments for `{tool}`: {detail}"), + ComposioErrorClass::InsufficientScope => format_insufficient_scope_message(tool, detail), + ComposioErrorClass::RateLimited => format_rate_limited_message(tool, detail), + ComposioErrorClass::UpstreamProvider => { + format!("`{tool}` failed at the connected provider: {detail}") + } + ComposioErrorClass::ComposioPlatform => { + format!("Composio connection issue for `{tool}`: {detail}") + } + ComposioErrorClass::Gateway => { + format!("Temporary gateway error while calling `{tool}`: {detail}") + } + ComposioErrorClass::Other => format!("`{tool}` failed: {detail}"), + }; + prefix_class(class, &body) +} + +pub fn remap_transport_error(tool: &str, raw: &str) -> String { + let detail = extract_transport_detail(raw); + let class = if is_embedded_provider_failure(&detail) { + classify_composio_error(tool, &detail) + } else if is_gateway_transport_shape(raw) { + ComposioErrorClass::Gateway + } else { + classify_composio_error(tool, raw) + }; + let body = match class { + ComposioErrorClass::InsufficientScope => format_insufficient_scope_message(tool, &detail), + ComposioErrorClass::RateLimited => format_rate_limited_message(tool, &detail), + ComposioErrorClass::Gateway => format!( + "Temporary gateway error while calling `{tool}`: {}", + summarize_gateway(raw) + ), + ComposioErrorClass::Validation => format!("Invalid arguments for `{tool}`: {detail}"), + ComposioErrorClass::UpstreamProvider => { + format!("`{tool}` failed at the connected provider: {detail}") + } + ComposioErrorClass::ComposioPlatform => { + format!("Composio connection issue for `{tool}`: {detail}") + } + ComposioErrorClass::Other => format!("`{tool}` failed: {detail}"), + }; + prefix_class(class, &body) +} + +fn prefix_class(class: ComposioErrorClass, body: &str) -> String { + format!("[composio:error:{}] {}", class.as_str(), body) +} + +fn format_insufficient_scope_message(tool: &str, detail: &str) -> String { + let toolkit = tool + .split('_') + .next() + .unwrap_or("integration") + .to_ascii_lowercase(); + format!( + "`{tool}` was rejected because the connected {toolkit} account is missing required \ + permissions ({detail}). Reconnect the integration in Settings → Skills and grant the \ + scopes requested during OAuth." + ) +} + +fn format_rate_limited_message(tool: &str, detail: &str) -> String { + format!( + "`{tool}` hit an upstream rate limit ({detail}). Wait a minute and retry, or reduce \ + call frequency — this is not an OpenHuman gateway outage." + ) +} + +fn is_validation_shape(lower: &str) -> bool { + lower.contains("invalid arguments") + || lower.contains("missing required") + || lower.contains("must not be empty") + || lower.contains("required field") + || lower.contains("bad request") + || lower.contains("invalid date") + || lower.contains("rfc 3339") + || lower.contains("timemax") + || lower.contains("timemin") +} + +fn is_insufficient_scope_shape(lower: &str) -> bool { + lower.contains("insufficient authentication scopes") + || lower.contains("insufficient scope") + || lower.contains("insufficient permissions") + || (lower.contains("403") && lower.contains("scope")) + || lower.contains("invalid oauth scope") +} + +fn is_rate_limited_shape(lower: &str) -> bool { + lower.contains("rate limit") + || lower.contains("rate_limit") + || lower.contains("ratelimited") + || lower.contains("too many requests") + || lower.contains("429") +} + +fn is_composio_platform_shape(lower: &str) -> bool { + lower.contains("connection error, try to authenticate") + || lower.contains("not enabled") + || lower.contains("not connected") + || lower.contains("token revoked") +} + +fn is_gateway_transport_shape(lower: &str) -> bool { + lower.contains("backend returned 502") + || lower.contains("502 bad gateway") + || lower.contains("backend returned 503") + || lower.contains("backend returned 504") + || lower.contains("(502 ") + || lower.contains("(503 ") + || lower.contains("(504 ") +} + +fn is_embedded_provider_failure(lower: &str) -> bool { + is_validation_shape(lower) + || is_insufficient_scope_shape(lower) + || is_rate_limited_shape(lower) + || is_composio_platform_shape(lower) + || lower.contains("composio") + || lower.contains("google") + || lower.contains("slack") + || lower.contains("notion") + || lower.contains("gmail") + || lower.contains("fetch_type") + || lower.contains("timemax") + || lower.contains("timemin") +} + +fn extract_transport_detail(raw: &str) -> String { + raw.split_once(": ") + .map(|(_, tail)| tail.to_string()) + .unwrap_or_else(|| raw.to_string()) +} + +fn summarize_gateway(raw: &str) -> String { + if let Some(idx) = raw.find("Backend returned ") { + let rest = &raw[idx..]; + if let Some(colon) = rest.rfind(": ") { + return rest[colon + 2..].trim().to_string(); + } + return rest.trim().to_string(); + } + raw.trim().to_string() +} + +#[cfg(test)] +#[path = "error_mapping_tests.rs"] +mod tests; diff --git a/src/openhuman/composio/error_mapping_tests.rs b/src/openhuman/composio/error_mapping_tests.rs new file mode 100644 index 0000000000..4933c2d959 --- /dev/null +++ b/src/openhuman/composio/error_mapping_tests.rs @@ -0,0 +1,44 @@ +use super::{classify_composio_error, remap_transport_error, ComposioErrorClass}; + +#[test] +fn classifies_gmail_insufficient_scope() { + let msg = "HTTP 403: Request had insufficient authentication scopes."; + assert_eq!( + classify_composio_error("GMAIL_FETCH_EMAILS", msg), + ComposioErrorClass::InsufficientScope + ); +} + +#[test] +fn classifies_slack_rate_limit() { + let msg = "Slack API error: ratelimited"; + assert_eq!( + classify_composio_error("SLACK_FETCH_CONVERSATION_HISTORY", msg), + ComposioErrorClass::RateLimited + ); +} + +#[test] +fn embedded_provider_failure_in_502_body_is_not_gateway() { + let raw = "Backend returned 502 Bad Gateway for POST https://api.example.com/agent-integrations/composio/execute: \ + timeMax must be RFC 3339 timestamp"; + let mapped = remap_transport_error("GOOGLECALENDAR_EVENTS_LIST", raw); + assert!( + mapped.contains("[composio:error:"), + "expected classified prefix, got: {mapped}" + ); + assert!( + !mapped.contains("[composio:error:gateway]"), + "provider-shaped 502 body must not be labeled gateway: {mapped}" + ); +} + +#[test] +fn true_gateway_stays_gateway_class() { + let raw = "Backend returned 502 Bad Gateway for POST https://api.example.com/x: upstream down"; + let mapped = remap_transport_error("GMAIL_SEND_EMAIL", raw); + assert!( + mapped.contains("[composio:error:gateway]"), + "expected gateway class, got: {mapped}" + ); +} diff --git a/src/openhuman/composio/execute_dispatch.rs b/src/openhuman/composio/execute_dispatch.rs new file mode 100644 index 0000000000..4eddab64e9 --- /dev/null +++ b/src/openhuman/composio/execute_dispatch.rs @@ -0,0 +1,113 @@ +//! Shared Composio execute path: prepare args, retry policy, error mapping (#1797). + +use std::time::Duration; + +use super::auth_retry::{execute_with_auth_retry_inner, AUTH_RETRY_BACKOFF}; +use super::client::ComposioClient; +use super::error_mapping::format_provider_error; +use super::execute_prepare::prepare_execute_arguments; +use super::types::ComposioExecuteResponse; + +const SLACK_HISTORY: &str = "SLACK_FETCH_CONVERSATION_HISTORY"; +const RATELIMIT_INITIAL_BACKOFF: Duration = Duration::from_secs(2); +const RATELIMIT_MAX_BACKOFF: Duration = Duration::from_secs(30); +const RATELIMIT_MAX_ATTEMPTS: u32 = 6; + +pub async fn execute_composio_action( + client: &ComposioClient, + tool: &str, + arguments: Option, +) -> Result { + let tool = tool.trim(); + if tool.is_empty() { + return Err("composio: tool slug must not be empty".to_string()); + } + + let prepared = match prepare_execute_arguments(tool, arguments) { + Ok(args) => args, + Err(msg) => { + tracing::debug!( + tool = %tool, + "[composio][prepare] local validation rejected execute" + ); + return Err(format_provider_error(tool, &msg)); + } + }; + + tracing::debug!(tool = %tool, "[composio][dispatch] execute_composio_action"); + let resp = match execute_with_retries(client, tool, prepared).await { + Ok(resp) => resp, + Err(e) => { + tracing::debug!(tool = %tool, "[composio][dispatch] transport failure"); + return Err(e.to_string()); + } + }; + + if resp.successful { + return Ok(resp); + } + + let raw_err = resp + .error + .clone() + .unwrap_or_else(|| "provider reported failure".to_string()); + Ok(ComposioExecuteResponse { + error: Some(format_provider_error(tool, &raw_err)), + ..resp + }) +} + +async fn execute_with_retries( + client: &ComposioClient, + tool: &str, + args: serde_json::Value, +) -> anyhow::Result { + let mut delay = RATELIMIT_INITIAL_BACKOFF; + for attempt in 1..=RATELIMIT_MAX_ATTEMPTS { + let resp = execute_with_auth_retry_inner( + client, + tool, + Some(args.clone()), + if attempt == 1 { + AUTH_RETRY_BACKOFF + } else { + Duration::ZERO + }, + ) + .await?; + + if resp.successful { + return Ok(resp); + } + + let err_text = resp.error.as_deref().unwrap_or(""); + if tool == SLACK_HISTORY && is_rate_limited(err_text) && attempt < RATELIMIT_MAX_ATTEMPTS { + tracing::warn!( + tool = %tool, + attempt, + max_attempts = RATELIMIT_MAX_ATTEMPTS, + sleep_ms = delay.as_millis() as u64, + "[composio][dispatch] upstream rate limit; backing off (#1797)" + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(RATELIMIT_MAX_BACKOFF); + continue; + } + + return Ok(resp); + } + unreachable!("loop returns on final attempt"); +} + +fn is_rate_limited(err: &str) -> bool { + let lower = err.to_ascii_lowercase(); + lower.contains("rate limit") + || lower.contains("rate_limit") + || lower.contains("ratelimited") + || lower.contains("too many requests") + || lower.contains("429") +} + +#[cfg(test)] +#[path = "execute_dispatch_tests.rs"] +mod tests; diff --git a/src/openhuman/composio/execute_dispatch_tests.rs b/src/openhuman/composio/execute_dispatch_tests.rs new file mode 100644 index 0000000000..c363cd2563 --- /dev/null +++ b/src/openhuman/composio/execute_dispatch_tests.rs @@ -0,0 +1,60 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use axum::{routing::post, Json, Router}; +use serde_json::json; +use tokio::sync::oneshot; + +use super::execute_composio_action; +use crate::openhuman::composio::client::ComposioClient; +use crate::openhuman::integrations::IntegrationClient; + +async fn start_mock_backend(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = rx.await; + }) + .await + .unwrap(); + }); + drop(tx); + format!("http://{addr}") +} + +fn build_client(base: &str) -> ComposioClient { + let inner = Arc::new(IntegrationClient::new( + base.to_string(), + "test-token".to_string(), + )); + ComposioClient::new(inner) +} + +#[tokio::test] +async fn local_validation_skips_network() { + let attempts = Arc::new(AtomicUsize::new(0)); + let app = Router::new().route( + "/agent-integrations/composio/execute", + post({ + let attempts = attempts.clone(); + move || async move { + attempts.fetch_add(1, Ordering::SeqCst); + Json(json!({"success": true, "data": {"successful": true, "data": {}, "costUsd": 0.0}})) + } + }), + ); + let base = start_mock_backend(app).await; + let client = build_client(&base); + let err = execute_composio_action( + &client, + "GMAIL_SEND_EMAIL", + Some(json!({ "subject": "hello" })), + ) + .await + .unwrap_err(); + assert!(err.contains("[composio:error:")); + assert_eq!(attempts.load(Ordering::SeqCst), 0); +} diff --git a/src/openhuman/composio/execute_prepare.rs b/src/openhuman/composio/execute_prepare.rs new file mode 100644 index 0000000000..bf83115838 --- /dev/null +++ b/src/openhuman/composio/execute_prepare.rs @@ -0,0 +1,160 @@ +//! Normalize and validate Composio action arguments before dispatch (#1797). + +use serde_json::{json, Map, Value}; + +pub fn prepare_execute_arguments(tool: &str, arguments: Option) -> Result { + let tool = tool.trim(); + let mut args = match arguments { + Some(Value::Object(map)) => Value::Object(map), + Some(Value::Null) | None => Value::Object(Map::new()), + Some(other) => { + return Err(format!( + "composio: `{tool}` arguments must be a JSON object, got {}", + other + )); + } + }; + + if tool.starts_with("GOOGLECALENDAR_") { + normalize_calendar_time_bounds(&mut args)?; + } + if tool == "NOTION_FETCH_DATA" { + ensure_notion_fetch_type(&mut args)?; + } + if tool == "GMAIL_SEND_EMAIL" { + validate_gmail_send_email(&args)?; + } + if tool == "GMAIL_ADD_LABEL_TO_EMAIL" { + validate_gmail_add_label(&args)?; + } + + Ok(args) +} + +fn normalize_calendar_time_bounds(args: &mut Value) -> Result<(), String> { + let Some(obj) = args.as_object_mut() else { + return Ok(()); + }; + for key in ["timeMin", "timeMax", "time_min", "time_max"] { + if let Some(v) = obj.get(key).cloned() { + if let Some(normalized) = normalize_rfc3339_bound(&v) { + obj.insert(key.to_string(), Value::String(normalized)); + } else if v.is_string() { + return Err(format!( + "GOOGLECALENDAR time bound `{key}` must be an RFC 3339 timestamp \ + (e.g. 2026-05-14T00:00:00Z), not a bare date" + )); + } + } + } + Ok(()) +} + +fn normalize_rfc3339_bound(value: &Value) -> Option { + let s = value.as_str()?.trim(); + if s.is_empty() { + return None; + } + if s.contains('T') { + return Some(s.to_string()); + } + if s.len() == 10 && s.as_bytes().get(4) == Some(&b'-') && s.as_bytes().get(7) == Some(&b'-') { + return Some(format!("{s}T00:00:00Z")); + } + None +} + +fn ensure_notion_fetch_type(args: &mut Value) -> Result<(), String> { + let Some(obj) = args.as_object_mut() else { + return Ok(()); + }; + let has_fetch_type = obj + .get("fetch_type") + .or_else(|| obj.get("fetchType")) + .and_then(|v| v.as_str()) + .map(str::trim) + .is_some_and(|s| !s.is_empty()); + if has_fetch_type { + return Ok(()); + } + let inferred = obj + .get("filter") + .and_then(|f| f.get("value").or_else(|| f.get("property"))) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|v| match v { + "page" | "pages" => "pages", + "database" | "databases" => "databases", + other => other, + }) + .unwrap_or("pages"); + tracing::debug!( + fetch_type = %inferred, + "[composio][prepare] NOTION_FETCH_DATA: inferred fetch_type" + ); + obj.insert("fetch_type".to_string(), json!(inferred)); + Ok(()) +} + +fn validate_gmail_send_email(args: &Value) -> Result<(), String> { + let Some(obj) = args.as_object() else { + return Err("GMAIL_SEND_EMAIL: arguments must be an object".to_string()); + }; + let recipient = obj + .get("to") + .or_else(|| obj.get("recipient_email")) + .or_else(|| obj.get("recipientEmail")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + if recipient.is_some() { + return Ok(()); + } + Err( + "GMAIL_SEND_EMAIL: `to` (or `recipient_email`) is required — cannot send without a recipient" + .to_string(), + ) +} + +fn validate_gmail_add_label(args: &Value) -> Result<(), String> { + let Some(obj) = args.as_object() else { + return Err("GMAIL_ADD_LABEL_TO_EMAIL: arguments must be an object".to_string()); + }; + let message_id = obj + .get("message_id") + .or_else(|| obj.get("messageId")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + if message_id.is_none() { + return Err("GMAIL_ADD_LABEL_TO_EMAIL: `message_id` is required".to_string()); + } + let add = non_empty_string_array(obj.get("add_label_ids").or_else(|| obj.get("addLabelIds"))); + let remove = non_empty_string_array( + obj.get("remove_label_ids") + .or_else(|| obj.get("removeLabelIds")), + ); + if add || remove { + return Ok(()); + } + Err( + "GMAIL_ADD_LABEL_TO_EMAIL: provide at least one non-empty label in `add_label_ids` or \ + `remove_label_ids`" + .to_string(), + ) +} + +fn non_empty_string_array(value: Option<&Value>) -> bool { + match value { + Some(Value::Array(items)) => items + .iter() + .any(|v| v.as_str().map(str::trim).is_some_and(|s| !s.is_empty())), + Some(Value::String(s)) => !s.trim().is_empty(), + _ => false, + } +} + +#[cfg(test)] +#[path = "execute_prepare_tests.rs"] +mod tests; diff --git a/src/openhuman/composio/execute_prepare_tests.rs b/src/openhuman/composio/execute_prepare_tests.rs new file mode 100644 index 0000000000..574e26c974 --- /dev/null +++ b/src/openhuman/composio/execute_prepare_tests.rs @@ -0,0 +1,41 @@ +use serde_json::json; + +use super::prepare_execute_arguments; + +#[test] +fn calendar_bare_date_becomes_rfc3339() { + let args = json!({ + "timeMin": "2026-05-14", + "timeMax": "2026-05-15" + }); + let prepared = prepare_execute_arguments("GOOGLECALENDAR_EVENTS_LIST", Some(args)).unwrap(); + assert_eq!(prepared["timeMin"], "2026-05-14T00:00:00Z"); + assert_eq!(prepared["timeMax"], "2026-05-15T00:00:00Z"); +} + +#[test] +fn notion_fetch_data_infers_fetch_type_from_filter() { + let args = json!({ + "filter": { "value": "page", "property": "object" }, + "page_size": 25 + }); + let prepared = prepare_execute_arguments("NOTION_FETCH_DATA", Some(args)).unwrap(); + assert_eq!(prepared["fetch_type"], "pages"); +} + +#[test] +fn gmail_send_requires_recipient() { + let err = prepare_execute_arguments("GMAIL_SEND_EMAIL", Some(json!({ "subject": "hi" }))) + .unwrap_err(); + assert!(err.contains("recipient") || err.contains("`to`")); +} + +#[test] +fn gmail_add_label_requires_label_ids() { + let err = prepare_execute_arguments( + "GMAIL_ADD_LABEL_TO_EMAIL", + Some(json!({ "message_id": "m1", "add_label_ids": [], "remove_label_ids": [] })), + ) + .unwrap_err(); + assert!(err.contains("add_label_ids") || err.contains("remove_label_ids")); +} diff --git a/src/openhuman/composio/mod.rs b/src/openhuman/composio/mod.rs index 03c55f46a8..59fb5584d4 100644 --- a/src/openhuman/composio/mod.rs +++ b/src/openhuman/composio/mod.rs @@ -39,6 +39,9 @@ pub mod action_tool; pub mod auth_retry; pub mod bus; pub mod client; +pub mod error_mapping; +pub mod execute_dispatch; +pub mod execute_prepare; pub mod ops; pub mod periodic; pub mod providers; diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index 18c045abe8..73e25831bd 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -230,7 +230,7 @@ pub async fn composio_execute( tracing::debug!(tool = %tool, "[composio] rpc execute"); let client = resolve_client(config)?; let started = std::time::Instant::now(); - let result = client.execute_tool(tool, arguments).await; + let result = super::execute_dispatch::execute_composio_action(&client, tool, arguments).await; let elapsed_ms = started.elapsed().as_millis() as u64; match result { @@ -265,7 +265,7 @@ pub async fn composio_execute( elapsed_ms, }, ); - Err(format!("[composio] execute failed: {e:#}")) + Err(format!("[composio] execute failed: {e}")) } } } diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index ac6aceb2cd..969c0af3b1 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -697,10 +697,11 @@ impl Tool for ComposioExecuteTool { } let started = std::time::Instant::now(); - let res = super::auth_retry::execute_with_auth_retry(&self.client, &tool, arguments).await; + let res = + super::execute_dispatch::execute_composio_action(&self.client, &tool, arguments).await; let elapsed_ms = started.elapsed().as_millis() as u64; match res { - Ok(mut resp) => { + Ok(resp) => { crate::core::event_bus::publish_global( crate::core::event_bus::DomainEvent::ComposioActionExecuted { tool: tool.clone(), @@ -740,7 +741,7 @@ impl Tool for ComposioExecuteTool { elapsed_ms, }, ); - Ok(ToolResult::error(format!("composio_execute failed: {e}"))) + Ok(ToolResult::error(e)) } } } From 8fb2690a5b54181bd9b5c18ee49d0804168c733c Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Sat, 16 May 2026 00:42:21 +0530 Subject: [PATCH 3/5] composio: address PR #1827 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ops.rs: preserve `[composio:error:] …` tag from the dispatcher so the frontend formatter in app/src/lib/composio/formatters.ts can parse the class. Updated ops_tests::composio_execute_via_mock_propagates_backend_error to assert the new classified format. - error_mapping.rs: log classifier outcome once per call (tool + class) so the gateway-vs-provider decision is traceable. - execute_dispatch_tests.rs: drop the broken oneshot graceful-shutdown plumbing (sender was dropped immediately, signalling shutdown before the test could exercise the server). - execute_dispatch.rs: include the validation error message in the pre-flight rejection debug log; document why only SLACK_FETCH_CONVERSATION_HISTORY is allow-listed for transparent rate-limit retries. - execute_prepare.rs: validate bare YYYY-MM-DD with chrono::NaiveDate so impossible dates like 2026-99-99 are rejected up front instead of being passed through to Google Calendar. - client_tests.rs: fix execute_tool_surfaces_non_successful_provider_response to use a tool slug that bypasses local arg validation, so the test exercises the gateway-response path as intended. --- src/openhuman/composio/client_tests.rs | 4 +- src/openhuman/composio/error_mapping.rs | 40 ++++++++++--------- src/openhuman/composio/execute_dispatch.rs | 5 +++ .../composio/execute_dispatch_tests.rs | 14 +++---- src/openhuman/composio/execute_prepare.rs | 5 ++- src/openhuman/composio/ops.rs | 9 ++++- src/openhuman/composio/ops_tests.rs | 10 ++++- 7 files changed, 55 insertions(+), 32 deletions(-) diff --git a/src/openhuman/composio/client_tests.rs b/src/openhuman/composio/client_tests.rs index 1555d78289..3afa212e90 100644 --- a/src/openhuman/composio/client_tests.rs +++ b/src/openhuman/composio/client_tests.rs @@ -703,7 +703,9 @@ async fn execute_tool_surfaces_non_successful_provider_response() { ); let base = start_mock_backend(app).await; let client = build_client_for(base); - let resp = client.execute_tool("GMAIL_SEND_EMAIL", None).await.unwrap(); + // Use a slug that bypasses local arg validation in `execute_prepare` + // so the test exercises the gateway-response path, not the pre-flight. + let resp = client.execute_tool("ANY_TOOL", None).await.unwrap(); assert!( !resp.successful, "non-successful provider response must be surfaced via the successful flag" diff --git a/src/openhuman/composio/error_mapping.rs b/src/openhuman/composio/error_mapping.rs index 804c7721c4..46df293db6 100644 --- a/src/openhuman/composio/error_mapping.rs +++ b/src/openhuman/composio/error_mapping.rs @@ -32,29 +32,31 @@ impl ComposioErrorClass { pub fn classify_composio_error(tool: &str, message: &str) -> ComposioErrorClass { let lower = message.to_ascii_lowercase(); - if is_validation_shape(&lower) { - return ComposioErrorClass::Validation; - } - if is_insufficient_scope_shape(&lower) { - return ComposioErrorClass::InsufficientScope; - } - if is_rate_limited_shape(&lower) { - return ComposioErrorClass::RateLimited; - } - if is_gateway_transport_shape(&lower) && !is_embedded_provider_failure(&lower) { - return ComposioErrorClass::Gateway; - } - if is_composio_platform_shape(&lower) { - return ComposioErrorClass::ComposioPlatform; - } - if tool.starts_with("GMAIL_") + let class = if is_validation_shape(&lower) { + ComposioErrorClass::Validation + } else if is_insufficient_scope_shape(&lower) { + ComposioErrorClass::InsufficientScope + } else if is_rate_limited_shape(&lower) { + ComposioErrorClass::RateLimited + } else if is_gateway_transport_shape(&lower) && !is_embedded_provider_failure(&lower) { + ComposioErrorClass::Gateway + } else if is_composio_platform_shape(&lower) { + ComposioErrorClass::ComposioPlatform + } else if tool.starts_with("GMAIL_") || tool.starts_with("SLACK_") || tool.starts_with("NOTION_") || tool.starts_with("GOOGLECALENDAR_") { - return ComposioErrorClass::UpstreamProvider; - } - ComposioErrorClass::Other + ComposioErrorClass::UpstreamProvider + } else { + ComposioErrorClass::Other + }; + tracing::debug!( + tool = %tool, + class = class.as_str(), + "[composio][classify] error classified" + ); + class } pub fn format_provider_error(tool: &str, raw: &str) -> String { diff --git a/src/openhuman/composio/execute_dispatch.rs b/src/openhuman/composio/execute_dispatch.rs index 4eddab64e9..206d2a780a 100644 --- a/src/openhuman/composio/execute_dispatch.rs +++ b/src/openhuman/composio/execute_dispatch.rs @@ -28,6 +28,7 @@ pub async fn execute_composio_action( Err(msg) => { tracing::debug!( tool = %tool, + error = %msg, "[composio][prepare] local validation rejected execute" ); return Err(format_provider_error(tool, &msg)); @@ -81,6 +82,10 @@ async fn execute_with_retries( } let err_text = resp.error.as_deref().unwrap_or(""); + // Only Slack's conversations.history is allow-listed for transparent + // rate-limit retries today: it surfaces 429s on bursty agent reads and + // has stable retry semantics. Other tools surface 429 to the caller + // (formatted as `[composio:error:rate_limited]`) instead of stalling. if tool == SLACK_HISTORY && is_rate_limited(err_text) && attempt < RATELIMIT_MAX_ATTEMPTS { tracing::warn!( tool = %tool, diff --git a/src/openhuman/composio/execute_dispatch_tests.rs b/src/openhuman/composio/execute_dispatch_tests.rs index c363cd2563..22d2fa79c9 100644 --- a/src/openhuman/composio/execute_dispatch_tests.rs +++ b/src/openhuman/composio/execute_dispatch_tests.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use axum::{routing::post, Json, Router}; use serde_json::json; -use tokio::sync::oneshot; use super::execute_composio_action; use crate::openhuman::composio::client::ComposioClient; @@ -12,16 +11,13 @@ use crate::openhuman::integrations::IntegrationClient; async fn start_mock_backend(app: Router) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let (tx, rx) = oneshot::channel::<()>(); + // The spawned task is intentionally orphaned; it dies with the tokio + // runtime when the test finishes. The previous oneshot-based graceful + // shutdown was broken because the sender was dropped immediately, + // signalling shutdown before the test could exercise the server. tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(async { - let _ = rx.await; - }) - .await - .unwrap(); + let _ = axum::serve(listener, app).await; }); - drop(tx); format!("http://{addr}") } diff --git a/src/openhuman/composio/execute_prepare.rs b/src/openhuman/composio/execute_prepare.rs index bf83115838..f74218125f 100644 --- a/src/openhuman/composio/execute_prepare.rs +++ b/src/openhuman/composio/execute_prepare.rs @@ -58,7 +58,10 @@ fn normalize_rfc3339_bound(value: &Value) -> Option { if s.contains('T') { return Some(s.to_string()); } - if s.len() == 10 && s.as_bytes().get(4) == Some(&b'-') && s.as_bytes().get(7) == Some(&b'-') { + // A bare date like `2026-05-14` is promoted to RFC 3339 midnight UTC. + // Parse explicitly so impossible dates such as `2026-99-99` are rejected + // up front instead of being passed through to Google Calendar. + if chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() { return Some(format!("{s}T00:00:00Z")); } None diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index 73e25831bd..3ade83d238 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -265,7 +265,14 @@ pub async fn composio_execute( elapsed_ms, }, ); - Err(format!("[composio] execute failed: {e}")) + // Preserve already-classified errors from the dispatcher + // (`[composio:error:] …`) so the frontend formatter at + // `app/src/lib/composio/formatters.ts` can still parse the class. + if e.starts_with("[composio:error:") { + Err(e) + } else { + Err(format!("[composio] execute failed: {e}")) + } } } } diff --git a/src/openhuman/composio/ops_tests.rs b/src/openhuman/composio/ops_tests.rs index 5d8f4c83af..9a6cc6a047 100644 --- a/src/openhuman/composio/ops_tests.rs +++ b/src/openhuman/composio/ops_tests.rs @@ -377,7 +377,15 @@ async fn composio_execute_via_mock_propagates_backend_error() { let err = composio_execute(&config, "ANY_TOOL", None) .await .unwrap_err(); - assert!(err.contains("execute failed")); + // The dispatcher (`execute_composio_action`) classifies transport + // failures and prefixes them with `[composio:error:] …`; ops.rs + // preserves that prefix so the frontend formatter can parse the class. + // For an unrecognised tool slug and a 502-shaped envelope the only + // signal we get is the backend error text, so assert on its contents. + assert!( + err.starts_with("[composio:error:") && err.contains("rate limited"), + "got: {err}" + ); } #[tokio::test] From 4a76a6ad3400322203044021c4503db558a7e45f Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Sat, 16 May 2026 01:38:44 +0530 Subject: [PATCH 4/5] test(composio): cover empty-body fallback branches in formatComposioToolError Bring diff coverage for formatters.ts above the 80% gate by exercising the empty-body fallback messages for each classified error case (validation, insufficient_scope, rate_limited, gateway) plus the unknown-class default and null/undefined/empty inputs. --- app/src/lib/composio/formatters.test.ts | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/app/src/lib/composio/formatters.test.ts b/app/src/lib/composio/formatters.test.ts index f0991440c0..a059f71b37 100644 --- a/app/src/lib/composio/formatters.test.ts +++ b/app/src/lib/composio/formatters.test.ts @@ -13,6 +13,46 @@ describe('formatComposioToolError', () => { it('passes through unclassified messages', () => { expect(formatComposioToolError('plain failure')).toBe('plain failure'); }); + + it('returns empty string for null/undefined/empty input', () => { + expect(formatComposioToolError(null)).toBe(''); + expect(formatComposioToolError(undefined)).toBe(''); + expect(formatComposioToolError('')).toBe(''); + }); + + it('falls back to validation copy when body is empty', () => { + expect(formatComposioToolError('[composio:error:validation]')).toBe('Invalid tool arguments.'); + }); + + it('falls back to insufficient_scope copy when body is empty', () => { + expect(formatComposioToolError('[composio:error:insufficient_scope]')).toBe( + 'Reconnect this integration and grant the requested permissions.' + ); + }); + + it('falls back to rate_limited copy when body is empty', () => { + expect(formatComposioToolError('[composio:error:rate_limited]')).toBe( + 'The upstream service is rate-limiting requests. Try again shortly.' + ); + }); + + it('falls back to gateway copy when body is empty', () => { + expect(formatComposioToolError('[composio:error:gateway]')).toBe( + 'Temporary connection issue. Try again in a moment.' + ); + }); + + it('returns body for unknown class when present', () => { + expect(formatComposioToolError('[composio:error:something_new] details here')).toBe( + 'details here' + ); + }); + + it('falls back to trimmed raw for unknown class with empty body', () => { + expect(formatComposioToolError(' [composio:error:something_new] ')).toBe( + '[composio:error:something_new]' + ); + }); }); describe('formatTriggerLabel', () => { From 6bcc5da627109af2157d51496aeeb8b2e77a9bde Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 15 May 2026 20:18:43 -0700 Subject: [PATCH 5/5] fix(pr-1827): address remaining CodeRabbit review items - composio/ops.rs: emit branch-level tracing when mapping execute errors (classified vs wrapped) so incident triage can grep the decision (CodeRabbit #3250474827). - socket/event_handlers.rs: stop logging raw payload bytes at debug level; emit byte-length + top-level shape only so PII / secrets / auth tokens in socket frames cannot leak through debug logs (CodeRabbit #3250222027). - composio/tools.rs: rustfmt collapse of the `super::client` import group left over after dropping `direct_execute`. --- src/openhuman/composio/ops.rs | 9 ++++++++- src/openhuman/composio/tools.rs | 4 +--- src/openhuman/socket/event_handlers.rs | 17 ++++++++++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index 20728354e4..56279288e3 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -621,7 +621,14 @@ pub async fn composio_execute( // Preserve already-classified errors from the dispatcher // (`[composio:error:] …`) so the frontend formatter at // `app/src/lib/composio/formatters.ts` can still parse the class. - if e.starts_with("[composio:error:") { + let is_classified = e.starts_with("[composio:error:"); + tracing::debug!( + tool = %tool, + elapsed_ms, + classified = is_classified, + "[composio] rpc execute error mapped" + ); + if is_classified { Err(e) } else { Err(format!("[composio] execute failed: {e}")) diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index 85a210b615..5c98b4a498 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -34,9 +34,7 @@ use crate::openhuman::tools::traits::{ PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolResult, }; -use super::client::{ - create_composio_client, direct_list_connections, ComposioClientKind, -}; +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, diff --git a/src/openhuman/socket/event_handlers.rs b/src/openhuman/socket/event_handlers.rs index 35e77f6cba..74d91da537 100644 --- a/src/openhuman/socket/event_handlers.rs +++ b/src/openhuman/socket/event_handlers.rs @@ -37,11 +37,22 @@ pub(super) fn handle_sio_event( event_name, payload.len() ); - let preview_end = crate::openhuman::util::floor_char_boundary(&payload, 500); + // CodeRabbit #3250222027: even at debug level, raw bodies can leak + // PII / secrets / tokens. Log structural metadata (top-level shape + + // byte length) but never the raw text. + let payload_shape = match &data { + serde_json::Value::Object(map) => format!("object_keys={}", map.len()), + serde_json::Value::Array(arr) => format!("array_len={}", arr.len()), + serde_json::Value::String(_) => "string".to_string(), + serde_json::Value::Number(_) => "number".to_string(), + serde_json::Value::Bool(_) => "bool".to_string(), + serde_json::Value::Null => "null".to_string(), + }; log::debug!( - "[socket] event payload: name={} data={}", + "[socket] event payload: name={} data_bytes={} shape={} preview_omitted=true", event_name, - &payload[..preview_end] + payload.len(), + payload_shape ); log::debug!("[socket] event dispatch: name={}", event_name);