Skip to main content

mubit_sdk/
lib.rs

1pub mod contract;
2pub mod learn;
3
4// Module structure for SDK restructuring.
5// Types are still defined in this file but these modules establish the
6// target layout: config, error, transport/, domains/, helpers/.
7pub mod config;
8pub mod error;
9pub mod transport;
10pub mod domains;
11pub mod helpers;
12
13pub mod proto {
14    pub mod mubit {
15        pub mod v1 {
16            tonic::include_proto!("mubit.v1");
17        }
18    }
19}
20
21use crate::contract::{find_operation, HttpMethod, OperationSpec};
22use reqwest::header::CONTENT_TYPE;
23use serde::de::DeserializeOwned;
24use serde::Serialize;
25use serde_json::{json, Map, Value};
26use std::collections::HashSet;
27use std::pin::Pin;
28use std::sync::{Arc, RwLock};
29use std::time::{Duration, Instant};
30use thiserror::Error;
31use tokio::sync::Mutex;
32use tokio::time::sleep;
33use tokio_stream::{Stream, StreamExt};
34use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
35use tonic::{Code, Request, Status};
36use url::Url;
37
38/// A boxed stream of JSON values for server-streaming RPCs.
39pub type ValueStream = Pin<Box<dyn Stream<Item = Result<Value>> + Send>>;
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum TransportMode {
43    Auto,
44    Grpc,
45    Http,
46}
47
48impl TransportMode {
49    fn normalize(raw: &str) -> Self {
50        match raw.trim().to_lowercase().as_str() {
51            "grpc" => Self::Grpc,
52            "http" => Self::Http,
53            _ => Self::Auto,
54        }
55    }
56}
57
58const DEFAULT_SHARED_HTTP_ENDPOINT: &str = "https://api.mubit.ai";
59const DEFAULT_SHARED_GRPC_ENDPOINT: &str = "grpc.api.mubit.ai:443";
60
61fn env_non_empty(name: &str) -> Option<String> {
62    std::env::var(name)
63        .ok()
64        .map(|value| value.trim().to_string())
65        .filter(|value| !value.is_empty())
66}
67
68#[derive(Clone, Debug)]
69pub struct ClientConfig {
70    pub endpoint: String,
71    pub grpc_endpoint: Option<String>,
72    pub http_endpoint: Option<String>,
73    pub transport: TransportMode,
74    pub api_key: Option<String>,
75    pub token: Option<String>, // Backward-compatible alias for api_key in 0.3.x
76    pub run_id: Option<String>,
77    pub timeout_ms: u64,
78}
79
80impl ClientConfig {
81    pub fn new(endpoint: impl Into<String>) -> Self {
82        Self {
83            endpoint: endpoint.into(),
84            grpc_endpoint: None,
85            http_endpoint: None,
86            transport: TransportMode::Auto,
87            api_key: None,
88            token: None,
89            run_id: None,
90            timeout_ms: 30_000,
91        }
92    }
93
94    pub fn transport(mut self, transport: impl AsRef<str>) -> Self {
95        self.transport = TransportMode::normalize(transport.as_ref());
96        self
97    }
98
99    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
100        self.api_key = Some(api_key.into());
101        self
102    }
103
104    pub fn token(mut self, token: impl Into<String>) -> Self {
105        self.api_key = Some(token.into());
106        self
107    }
108
109    pub fn run_id(mut self, run_id: impl Into<String>) -> Self {
110        self.run_id = Some(run_id.into());
111        self
112    }
113
114    pub fn from_env() -> Self {
115        Self::default()
116    }
117}
118
119impl Default for ClientConfig {
120    fn default() -> Self {
121        let transport = env_non_empty("MUBIT_TRANSPORT")
122            .map(|value| TransportMode::normalize(&value))
123            .unwrap_or(TransportMode::Auto);
124        let endpoint = env_non_empty("MUBIT_ENDPOINT")
125            .unwrap_or_else(|| DEFAULT_SHARED_HTTP_ENDPOINT.to_string());
126
127        let mut config = Self::new(endpoint);
128        config.transport = transport;
129        config.http_endpoint = env_non_empty("MUBIT_HTTP_ENDPOINT");
130        config.grpc_endpoint = env_non_empty("MUBIT_GRPC_ENDPOINT");
131        config.api_key = env_non_empty("MUBIT_API_KEY");
132        config.token = env_non_empty("MUBIT_TOKEN");
133        config.run_id = env_non_empty("MUBIT_RUN_ID");
134
135        if config.http_endpoint.is_none() {
136            config.http_endpoint = Some(DEFAULT_SHARED_HTTP_ENDPOINT.to_string());
137        }
138        if config.grpc_endpoint.is_none() {
139            config.grpc_endpoint = Some(DEFAULT_SHARED_GRPC_ENDPOINT.to_string());
140        }
141
142        config
143    }
144}
145
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
147pub enum TransportFailureKind {
148    Unavailable,
149    ConnectionReset,
150    DeadlineExceeded,
151    Io,
152    Unimplemented,
153    Other,
154}
155
156#[derive(Error, Debug)]
157pub enum SdkError {
158    #[error("AuthError: {0}")]
159    AuthError(String),
160    #[error("ValidationError: {0}")]
161    ValidationError(String),
162    #[error("TransportError({kind:?}): {message}")]
163    TransportError {
164        kind: TransportFailureKind,
165        message: String,
166    },
167    #[error("ServerError: {0}")]
168    ServerError(String),
169    #[error("UnsupportedFeatureError: {0}")]
170    UnsupportedFeatureError(String),
171}
172
173impl SdkError {
174    fn is_fallback_eligible(&self) -> bool {
175        matches!(
176            self,
177            SdkError::TransportError {
178                kind: TransportFailureKind::Unavailable
179                    | TransportFailureKind::ConnectionReset
180                    | TransportFailureKind::DeadlineExceeded
181                    | TransportFailureKind::Io
182                    | TransportFailureKind::Unimplemented,
183                ..
184            }
185        )
186    }
187
188    /// True for transient failures worth retrying. Auth, validation, and
189    /// unsupported-feature errors never retry. 5xx/transport-layer errors do.
190    fn is_retryable(&self) -> bool {
191        match self {
192            SdkError::TransportError { kind, .. } => matches!(
193                kind,
194                TransportFailureKind::Unavailable
195                    | TransportFailureKind::ConnectionReset
196                    | TransportFailureKind::DeadlineExceeded
197                    | TransportFailureKind::Io
198            ),
199            SdkError::ServerError(_) => true,
200            SdkError::AuthError(_)
201            | SdkError::ValidationError(_)
202            | SdkError::UnsupportedFeatureError(_) => false,
203        }
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Retry config (env-driven, same knobs as the Python + JS SDKs).
209// MUBIT_RETRY_ATTEMPTS, MUBIT_RETRY_BASE_MS, MUBIT_RETRY_CAP_MS, MUBIT_RETRY_JITTER.
210// ---------------------------------------------------------------------------
211fn retry_env_u64(name: &str, default: u64, minimum: u64) -> u64 {
212    std::env::var(name)
213        .ok()
214        .and_then(|v| v.trim().parse::<u64>().ok())
215        .map(|v| v.max(minimum))
216        .unwrap_or(default)
217}
218
219fn retry_env_f64(name: &str, default: f64, minimum: f64) -> f64 {
220    std::env::var(name)
221        .ok()
222        .and_then(|v| v.trim().parse::<f64>().ok())
223        .map(|v| v.max(minimum))
224        .unwrap_or(default)
225}
226
227fn retry_attempts() -> u32 {
228    retry_env_u64("MUBIT_RETRY_ATTEMPTS", 3, 1) as u32
229}
230
231fn retry_base_ms() -> u64 {
232    retry_env_u64("MUBIT_RETRY_BASE_MS", 200, 10)
233}
234
235fn retry_cap_ms() -> u64 {
236    retry_env_u64("MUBIT_RETRY_CAP_MS", 5000, retry_base_ms())
237}
238
239fn retry_jitter() -> f64 {
240    retry_env_f64("MUBIT_RETRY_JITTER", 0.2, 0.0)
241}
242
243fn backoff_delay_ms(attempt: u32) -> Duration {
244    if attempt <= 1 {
245        return Duration::ZERO;
246    }
247    let base = retry_base_ms();
248    let cap = retry_cap_ms();
249    let exp = std::cmp::min(base.saturating_mul(1u64 << (attempt - 2)), cap) as f64;
250    let j = retry_jitter();
251    let ms = if j > 0.0 {
252        // Pseudo-random jitter from nanos-of-second — good enough for backoff.
253        let nanos = std::time::SystemTime::now()
254            .duration_since(std::time::UNIX_EPOCH)
255            .map(|d| d.subsec_nanos())
256            .unwrap_or(0);
257        let frac = (nanos as f64 / 1_000_000_000.0) * 2.0 - 1.0;
258        (exp * (1.0 + frac * j)).max(0.0) as u64
259    } else {
260        exp as u64
261    };
262    Duration::from_millis(ms)
263}
264
265pub type Result<T> = std::result::Result<T, SdkError>;
266
267#[derive(Clone, Debug)]
268struct MutableState {
269    api_key: Option<String>,
270    run_id: Option<String>,
271    transport: TransportMode,
272}
273
274struct TransportEngine {
275    http_endpoint: String,
276    grpc_endpoint: String,
277    grpc_tls: bool,
278    timeout: Duration,
279    http_client: reqwest::Client,
280    grpc_channel: Mutex<Option<Channel>>,
281    state: Arc<RwLock<MutableState>>,
282}
283
284impl TransportEngine {
285    fn new(config: ClientConfig) -> Result<Self> {
286        let (default_http_endpoint, default_grpc_endpoint, default_grpc_tls) =
287            derive_http_and_grpc(&config.endpoint)?;
288
289        let http_endpoint = match config.http_endpoint {
290            Some(http_endpoint) => normalize_http_endpoint(&http_endpoint)?,
291            None => default_http_endpoint,
292        };
293
294        let (grpc_endpoint, grpc_tls) = match config.grpc_endpoint {
295            Some(grpc_endpoint) => normalize_grpc_endpoint(&grpc_endpoint)?,
296            None => (default_grpc_endpoint, default_grpc_tls),
297        };
298
299        let timeout = Duration::from_millis(config.timeout_ms);
300        let http_client = reqwest::Client::builder()
301            .timeout(timeout)
302            .build()
303            .map_err(|e| SdkError::TransportError {
304                kind: TransportFailureKind::Other,
305                message: format!("failed to build HTTP client: {}", e),
306            })?;
307
308        Ok(Self {
309            http_endpoint,
310            grpc_endpoint,
311            grpc_tls,
312            timeout,
313            http_client,
314            grpc_channel: Mutex::new(None),
315            state: Arc::new(RwLock::new(MutableState {
316                api_key: config.api_key.or(config.token),
317                run_id: config.run_id,
318                transport: config.transport,
319            })),
320        })
321    }
322
323    fn set_api_key(&self, api_key: Option<String>) {
324        if let Ok(mut state) = self.state.write() {
325            state.api_key = api_key;
326        }
327    }
328
329    fn set_run_id(&self, run_id: Option<String>) {
330        if let Ok(mut state) = self.state.write() {
331            state.run_id = run_id;
332        }
333    }
334
335    fn set_transport(&self, transport: TransportMode) {
336        if let Ok(mut state) = self.state.write() {
337            state.transport = transport;
338        }
339    }
340
341    fn api_key(&self) -> Option<String> {
342        self.state.read().ok().and_then(|s| s.api_key.clone())
343    }
344
345    fn run_id(&self) -> Option<String> {
346        self.state.read().ok().and_then(|s| s.run_id.clone())
347    }
348
349    fn transport(&self) -> TransportMode {
350        self.state
351            .read()
352            .map(|s| s.transport)
353            .unwrap_or(TransportMode::Auto)
354    }
355
356    async fn invoke_serialized<T: Serialize>(&self, op_key: &str, payload: T) -> Result<Value> {
357        let value = serde_json::to_value(payload).map_err(|e| {
358            SdkError::ValidationError(format!("failed to serialize request payload: {}", e))
359        })?;
360        self.invoke(op_key, value).await
361    }
362
363    async fn invoke(&self, op_key: &str, payload: Value) -> Result<Value> {
364        let op = find_operation(op_key).ok_or_else(|| {
365            SdkError::UnsupportedFeatureError(format!("unknown operation: {}", op_key))
366        })?;
367
368        let mut payload = ensure_object_payload(payload)?;
369        self.apply_run_id_default(op, &mut payload);
370        let transport_mode = self.transport();
371
372        let attempts = retry_attempts();
373        let mut last_err: Option<SdkError> = None;
374        for attempt in 1..=attempts {
375            if attempt > 1 {
376                let delay = backoff_delay_ms(attempt);
377                if !delay.is_zero() {
378                    tokio::time::sleep(delay).await;
379                }
380            }
381            let result = match transport_mode {
382                TransportMode::Grpc => self.invoke_grpc(op, payload.clone()).await,
383                TransportMode::Http => self.invoke_http(op, payload.clone()).await,
384                TransportMode::Auto => match self.invoke_grpc(op, payload.clone()).await {
385                    Ok(value) => Ok(value),
386                    Err(err) if err.is_fallback_eligible() => {
387                        self.invoke_http(op, payload.clone()).await
388                    }
389                    Err(err) => Err(err),
390                },
391            };
392            match result {
393                Ok(value) => return Ok(value),
394                Err(err) => {
395                    if !err.is_retryable() || attempt >= attempts {
396                        return Err(err);
397                    }
398                    last_err = Some(err);
399                }
400            }
401        }
402        Err(last_err.unwrap_or_else(|| SdkError::TransportError {
403            kind: TransportFailureKind::Other,
404            message: "retry loop exited without result".to_string(),
405        }))
406    }
407
408    pub async fn invoke_stream(&self, key: &str, payload: Value) -> Result<ValueStream> {
409        let op = find_operation(key).ok_or_else(|| {
410            SdkError::UnsupportedFeatureError(format!("unknown operation: {}", key))
411        })?;
412
413        let mut payload = ensure_object_payload(payload)?;
414        self.apply_run_id_default(op, &mut payload);
415
416        match self.transport() {
417            TransportMode::Grpc => self.invoke_grpc_stream(op, payload).await,
418            TransportMode::Http => self.invoke_http_stream(op, payload).await,
419            TransportMode::Auto => match self.invoke_grpc_stream(op, payload.clone()).await {
420                Ok(stream) => Ok(stream),
421                Err(err) if err.is_fallback_eligible() => {
422                    self.invoke_http_stream(op, payload).await
423                }
424                Err(err) => Err(err),
425            },
426        }
427    }
428
429    pub async fn invoke_stream_serialized<T: Serialize>(
430        &self,
431        key: &str,
432        payload: T,
433    ) -> Result<ValueStream> {
434        let value = serde_json::to_value(payload).map_err(|e| {
435            SdkError::ValidationError(format!("failed to serialize payload: {}", e))
436        })?;
437        self.invoke_stream(key, value).await
438    }
439
440    fn apply_run_id_default(&self, op: &OperationSpec, payload: &mut Value) {
441        let Some(run_id_field) = op.run_id_field else {
442            return;
443        };
444        let Some(run_id) = self.run_id() else {
445            return;
446        };
447
448        if let Some(map) = payload.as_object_mut() {
449            if !map.contains_key(run_id_field) {
450                map.insert(run_id_field.to_string(), Value::String(run_id));
451            }
452        }
453    }
454
455    async fn grpc_channel(&self) -> Result<Channel> {
456        {
457            let guard = self.grpc_channel.lock().await;
458            if let Some(channel) = guard.as_ref() {
459                return Ok(channel.clone());
460            }
461        }
462
463        let uri = if self.grpc_tls {
464            format!("https://{}", self.grpc_endpoint)
465        } else {
466            format!("http://{}", self.grpc_endpoint)
467        };
468
469        let mut endpoint = Endpoint::from_shared(uri.clone()).map_err(|e| {
470            SdkError::ValidationError(format!("invalid gRPC endpoint '{}': {}", uri, e))
471        })?;
472        endpoint = endpoint.connect_timeout(self.timeout).timeout(self.timeout);
473        if self.grpc_tls {
474            endpoint = endpoint.tls_config(ClientTlsConfig::new()).map_err(|e| {
475                SdkError::TransportError {
476                    kind: TransportFailureKind::Other,
477                    message: format!("failed to configure TLS for {}: {}", self.grpc_endpoint, e),
478                }
479            })?;
480        }
481
482        let channel = endpoint
483            .connect()
484            .await
485            .map_err(|e| map_grpc_connect_error(e, &self.grpc_endpoint))?;
486
487        let mut guard = self.grpc_channel.lock().await;
488        *guard = Some(channel.clone());
489        Ok(channel)
490    }
491
492    fn attach_grpc_metadata<T>(&self, request: &mut Request<T>) {
493        if let Some(api_key) = self.api_key() {
494            if let Ok(header_value) = format!("Bearer {}", api_key).parse() {
495                request.metadata_mut().insert("authorization", header_value);
496            }
497        }
498    }
499
500    fn grpc_request<T>(&self, payload: T) -> Request<T> {
501        let mut request = Request::new(payload);
502        self.attach_grpc_metadata(&mut request);
503        request
504    }
505
506    async fn invoke_grpc(&self, op: &OperationSpec, payload: Value) -> Result<Value> {
507        use crate::proto::mubit::v1 as pb;
508
509        if op.grpc_method.is_empty() {
510            return Err(SdkError::TransportError {
511                kind: TransportFailureKind::Unimplemented,
512                message: format!("operation {} has no gRPC mapping", op.key),
513            });
514        }
515
516        let channel = self.grpc_channel().await?;
517
518        macro_rules! unary_core {
519            ($method:ident, $req_ty:ty) => {{
520                let request: $req_ty = decode_grpc_request(op.key, payload)?;
521                let mut client = pb::core_service_client::CoreServiceClient::new(channel.clone());
522                let response = client
523                    .$method(self.grpc_request(request))
524                    .await
525                    .map_err(map_grpc_status)?;
526                encode_grpc_response(op.key, response.into_inner())
527            }};
528        }
529
530        macro_rules! unary_control {
531            ($method:ident, $req_ty:ty) => {{
532                let request: $req_ty = decode_grpc_request(op.key, payload)?;
533                let mut client =
534                    pb::control_service_client::ControlServiceClient::new(channel.clone());
535                let response = client
536                    .$method(self.grpc_request(request))
537                    .await
538                    .map_err(map_grpc_status)?;
539                encode_grpc_response(op.key, response.into_inner())
540            }};
541        }
542
543        match op.key {
544            // auth
545            "auth.health" => unary_core!(health, pb::HealthRequest),
546            "auth.create_user" => unary_core!(create_user, pb::CreateUserRequest),
547            "auth.rotate_user_api_key" => {
548                unary_core!(rotate_user_api_key, pb::RotateUserApiKeyRequest)
549            }
550            "auth.revoke_user_api_key" => {
551                unary_core!(revoke_user_api_key, pb::RevokeUserApiKeyRequest)
552            }
553            "auth.list_users" => unary_core!(list_users, pb::ListUsersRequest),
554            "auth.get_user" => unary_core!(get_user, pb::GetUserRequest),
555            "auth.delete_user" => unary_core!(delete_user, pb::DeleteUserRequest),
556
557            // core
558            "core.insert" => unary_core!(insert, pb::InsertRequest),
559            "core.search" => unary_core!(search, pb::SearchRequest),
560            "core.delete_node" => unary_core!(delete_node, pb::DeleteNodeRequest),
561            "core.delete_run" => unary_core!(delete_run, pb::DeleteRunRequest),
562            "core.create_session" => unary_core!(create_session, pb::CreateSessionRequest),
563            "core.snapshot_session" => unary_core!(snapshot_session, pb::SnapshotSessionRequest),
564            "core.load_session" => unary_core!(load_session, pb::LoadSessionRequest),
565            "core.commit_session" => unary_core!(commit_session, pb::CommitSessionRequest),
566            "core.drop_session" => unary_core!(drop_session, pb::DropSessionRequest),
567            "core.write_memory" => unary_core!(write_memory, pb::WriteMemoryRequest),
568            "core.read_memory" => unary_core!(read_memory, pb::ReadMemoryRequest),
569            "core.add_memory" => unary_core!(add_memory, pb::AddMemoryRequest),
570            "core.get_memory" => unary_core!(get_memory, pb::GetMemoryRequest),
571            "core.clear_memory" => unary_core!(clear_memory, pb::ClearMemoryRequest),
572            "core.grant_permission" => unary_core!(grant_permission, pb::GrantPermissionRequest),
573            "core.revoke_permission" => unary_core!(revoke_permission, pb::RevokePermissionRequest),
574            "core.batch_insert" => {
575                let request_items = decode_batch_insert_payload(payload)?;
576                let mut request = Request::new(tokio_stream::iter(request_items));
577                self.attach_grpc_metadata(&mut request);
578                let mut client = pb::core_service_client::CoreServiceClient::new(channel.clone());
579                let response = client
580                    .batch_insert(request)
581                    .await
582                    .map_err(map_grpc_status)?;
583                encode_grpc_response(op.key, response.into_inner())
584            }
585            // control
586            "control.set_variable" => unary_control!(set_variable, pb::SetVariableRequest),
587            "control.get_variable" => unary_control!(get_variable, pb::GetVariableRequest),
588            "control.list_variables" => unary_control!(list_variables, pb::ListVariablesRequest),
589            "control.delete_variable" => unary_control!(delete_variable, pb::DeleteVariableRequest),
590            "control.define_concept" => unary_control!(define_concept, pb::DefineConceptRequest),
591            "control.list_concepts" => unary_control!(list_concepts, pb::ListConceptsRequest),
592            "control.add_goal" => unary_control!(add_goal, pb::AddGoalRequest),
593            "control.update_goal" => unary_control!(update_goal, pb::UpdateGoalRequest),
594            "control.list_goals" => unary_control!(list_goals, pb::ListGoalsRequest),
595            "control.get_goal_tree" => unary_control!(get_goal_tree, pb::GetGoalTreeRequest),
596            "control.submit_action" => unary_control!(submit_action, pb::ActionRequest),
597            "control.get_action_log" => unary_control!(get_action_log, pb::ActionLogRequest),
598            "control.run_cycle" => unary_control!(run_cycle, pb::RunCycleRequest),
599            "control.get_cycle_history" => {
600                unary_control!(get_cycle_history, pb::CycleHistoryRequest)
601            }
602            "control.register_agent" => unary_control!(register_agent, pb::AgentRegisterRequest),
603            "control.agent_heartbeat" => {
604                unary_control!(agent_heartbeat, pb::AgentHeartbeatRequest)
605            }
606            "control.append_activity" => unary_control!(append_activity, pb::ActivityAppendRequest),
607            "control.context_snapshot" => unary_control!(get_run_snapshot, pb::RunSnapshotRequest),
608            "control.link_run" => unary_control!(link_run, pb::LinkRunRequest),
609            "control.unlink_run" => unary_control!(unlink_run, pb::UnlinkRunRequest),
610            "control.ingest" => unary_control!(ingest, pb::IngestRequest),
611            "control.batch_insert" => {
612                unary_control!(batch_insert, pb::ControlBatchInsertRequest)
613            }
614            "control.get_ingest_job" => unary_control!(get_ingest_job, pb::GetIngestJobRequest),
615            "control.query" => unary_control!(query, pb::AgentQueryRequest),
616            "control.diagnose" => unary_control!(diagnose, pb::DiagnoseRequest),
617            "control.delete_run" => unary_control!(delete_run, pb::RunRequest),
618            "control.reflect" => unary_control!(reflect, pb::ReflectRequest),
619            "control.lessons" => unary_control!(list_lessons, pb::ListLessonsRequest),
620            "control.delete_lesson" => unary_control!(delete_lesson, pb::DeleteLessonRequest),
621            "control.context" => unary_control!(get_context, pb::ContextRequest),
622            "control.list_activity" => unary_control!(list_activity, pb::ListActivityRequest),
623            "control.export_activity" => {
624                unary_control!(export_activity, pb::ExportActivityRequest)
625            }
626            "control.archive_block" => unary_control!(archive_block, pb::ArchiveBlockRequest),
627            "control.dereference" => unary_control!(dereference, pb::DereferenceRequest),
628            "control.memory_health" => {
629                unary_control!(get_memory_health, pb::MemoryHealthRequest)
630            }
631            "control.checkpoint" => unary_control!(checkpoint, pb::CheckpointRequest),
632            "control.list_agents" => unary_control!(list_agents, pb::ListAgentsRequest),
633            "control.create_handoff" => unary_control!(create_handoff, pb::HandoffRequest),
634            "control.submit_feedback" => unary_control!(submit_feedback, pb::FeedbackRequest),
635            "control.record_outcome" => {
636                unary_control!(record_outcome, pb::RecordOutcomeRequest)
637            }
638            "control.surface_strategies" => {
639                unary_control!(surface_strategies, pb::SurfaceStrategiesRequest)
640            }
641            _ => Err(SdkError::UnsupportedFeatureError(format!(
642                "unknown gRPC operation: {}",
643                op.key
644            ))),
645        }
646    }
647
648    async fn invoke_grpc_stream(
649        &self,
650        op: &'static OperationSpec,
651        payload: Value,
652    ) -> Result<ValueStream> {
653        use crate::proto::mubit::v1 as pb;
654
655        let channel = self.grpc_channel().await?;
656
657        match op.key {
658            "core.subscribe_events" => {
659                let request: pb::CoreSubscribeRequest =
660                    decode_grpc_request(op.key, payload)?;
661                let mut client =
662                    pb::core_service_client::CoreServiceClient::new(channel.clone());
663                let response = client
664                    .subscribe(self.grpc_request(request))
665                    .await
666                    .map_err(map_grpc_status)?;
667                let stream = response.into_inner();
668                Ok(Box::pin(stream.map(|result| {
669                    result
670                        .map(|msg| {
671                            let mut value =
672                                serde_json::to_value(msg).unwrap_or_else(|e| {
673                                    serde_json::json!({"error": e.to_string()})
674                                });
675                            hydrate_pubsub_event(&mut value);
676                            value
677                        })
678                        .map_err(map_grpc_status)
679                })))
680            }
681            "control.subscribe" => {
682                let request: pb::SubscribeRequest =
683                    decode_grpc_request(op.key, payload)?;
684                let mut client =
685                    pb::control_service_client::ControlServiceClient::new(channel.clone());
686                let response = client
687                    .subscribe(self.grpc_request(request))
688                    .await
689                    .map_err(map_grpc_status)?;
690                let stream = response.into_inner();
691                Ok(Box::pin(stream.map(|result| {
692                    result
693                        .map(|msg| {
694                            serde_json::to_value(msg).unwrap_or_else(|e| {
695                                serde_json::json!({"error": e.to_string()})
696                            })
697                        })
698                        .map_err(map_grpc_status)
699                })))
700            }
701            "core.watch_memory" => {
702                let request: pb::WatchMemoryRequest =
703                    decode_grpc_request(op.key, payload)?;
704                let mut client =
705                    pb::core_service_client::CoreServiceClient::new(channel.clone());
706                let response = client
707                    .watch_memory(self.grpc_request(request))
708                    .await
709                    .map_err(map_grpc_status)?;
710                let stream = response.into_inner();
711                Ok(Box::pin(stream.map(|result| {
712                    result
713                        .map(|msg| {
714                            serde_json::to_value(msg).unwrap_or_else(|e| {
715                                serde_json::json!({"error": e.to_string()})
716                            })
717                        })
718                        .map_err(map_grpc_status)
719                })))
720            }
721            _ => Err(SdkError::UnsupportedFeatureError(format!(
722                "gRPC streaming not supported for {}",
723                op.key
724            ))),
725        }
726    }
727
728    async fn invoke_http_stream(
729        &self,
730        op: &'static OperationSpec,
731        payload: Value,
732    ) -> Result<ValueStream> {
733        let base = self.http_endpoint.trim_end_matches('/');
734        let route = op.http_path;
735        let url = format!("{}{}", base, route);
736
737        let client = &self.http_client;
738        let is_get = matches!(op.http_method, HttpMethod::Get);
739
740        let mut request = if is_get {
741            let mut req = client.get(&url);
742            if let Some(obj) = payload.as_object() {
743                for (k, v) in obj {
744                    let val = match v {
745                        Value::String(s) => s.clone(),
746                        other => other.to_string(),
747                    };
748                    req = req.query(&[(k.as_str(), val)]);
749                }
750            }
751            req
752        } else {
753            client.post(&url).json(&payload)
754        };
755
756        if let Some(api_key) = self.api_key() {
757            request = request.bearer_auth(api_key);
758        }
759
760        let response = request.send().await.map_err(|e| {
761            map_transport_error(e, format!("{} SSE request failed", op.key))
762        })?;
763
764        let status = response.status();
765        if !status.is_success() {
766            let body = response
767                .text()
768                .await
769                .unwrap_or_else(|_| "request failed".to_string());
770            return Err(map_http_error(status.as_u16(), body));
771        }
772
773        let byte_stream = response.bytes_stream();
774        let sse_stream = parse_sse_byte_stream(byte_stream);
775
776        // Per-op post-processing: pubsub events travel with metadata / memory
777        // entries as JSON-encoded strings so the proto stays a flat message.
778        // Parse them back into native objects on the SDK side so callers see
779        // the same shape regardless of transport.
780        if op.key == "core.subscribe_events" {
781            let hydrated = sse_stream.map(|result| {
782                result.map(|mut value| {
783                    hydrate_pubsub_event(&mut value);
784                    value
785                })
786            });
787            Ok(Box::pin(hydrated))
788        } else {
789            Ok(Box::pin(sse_stream))
790        }
791    }
792
793    async fn invoke_http(&self, op: &OperationSpec, payload: Value) -> Result<Value> {
794        let mut path = op.http_path.to_string();
795        let mut consumed_keys = HashSet::new();
796
797        if let Some(map) = payload.as_object() {
798            for (key, value) in map {
799                let marker = format!(":{}", key);
800                if path.contains(&marker) {
801                    let rendered = value_to_param(value).ok_or_else(|| {
802                        SdkError::ValidationError(format!(
803                            "invalid path parameter value for {} in {}",
804                            key, op.key
805                        ))
806                    })?;
807                    path = path.replace(&marker, &rendered);
808                    consumed_keys.insert(key.clone());
809                }
810            }
811        }
812
813        if path.contains(':') {
814            return Err(SdkError::ValidationError(format!(
815                "missing path parameter for {}",
816                op.key
817            )));
818        }
819
820        let url = format!("{}{}", self.http_endpoint.trim_end_matches('/'), path);
821        let mut request = match op.http_method {
822            HttpMethod::Get => self.http_client.get(&url),
823            HttpMethod::Post => self.http_client.post(&url),
824            HttpMethod::Delete => self.http_client.delete(&url),
825        };
826
827        if let Some(api_key) = self.api_key() {
828            request = request.bearer_auth(api_key);
829        }
830
831        if matches!(op.http_method, HttpMethod::Get) {
832            let query = payload
833                .as_object()
834                .map(|map| {
835                    map.iter()
836                        .filter_map(|(key, value)| {
837                            if consumed_keys.contains(key) || value.is_null() {
838                                return None;
839                            }
840                            value_to_query(value).map(|rendered| (key.clone(), rendered))
841                        })
842                        .collect::<Vec<(String, String)>>()
843                })
844                .unwrap_or_default();
845
846            if !query.is_empty() {
847                request = request.query(&query);
848            }
849        } else if payload
850            .as_object()
851            .map(|map| !map.is_empty())
852            .unwrap_or(false)
853        {
854            request = request.json(&payload);
855        }
856
857        let response = request.send().await.map_err(|e| {
858            map_transport_error(
859                e,
860                format!(
861                    "{} {} request failed",
862                    http_method_label(op.http_method),
863                    op.key
864                ),
865            )
866        })?;
867
868        let status = response.status();
869        if !status.is_success() {
870            let body = response
871                .text()
872                .await
873                .unwrap_or_else(|_| "request failed".to_string());
874            return Err(map_http_error(status.as_u16(), body));
875        }
876
877        let content_type = response
878            .headers()
879            .get(CONTENT_TYPE)
880            .and_then(|v| v.to_str().ok())
881            .map(|v| v.to_lowercase())
882            .unwrap_or_default();
883
884        let bytes = response
885            .bytes()
886            .await
887            .map_err(|e| SdkError::TransportError {
888                kind: TransportFailureKind::Io,
889                message: format!("failed to read response body for {}: {}", op.key, e),
890            })?;
891
892        if bytes.is_empty() {
893            return Ok(json!({}));
894        }
895
896        if content_type.contains("application/json") {
897            return serde_json::from_slice::<Value>(&bytes).map_err(|e| {
898                SdkError::ServerError(format!(
899                    "failed to decode json response for {}: {}",
900                    op.key, e
901                ))
902            });
903        }
904
905        Ok(Value::String(String::from_utf8_lossy(&bytes).to_string()))
906    }
907}
908
909#[derive(Clone)]
910pub struct Client {
911    pub auth: AuthClient,
912    pub core: CoreClient,
913    pub(crate) control: ControlClient,
914    transport: Arc<TransportEngine>,
915}
916
917impl Client {
918    pub fn new(config: ClientConfig) -> Result<Self> {
919        let transport = Arc::new(TransportEngine::new(config)?);
920        Ok(Self {
921            auth: AuthClient {
922                transport: transport.clone(),
923            },
924            core: CoreClient {
925                transport: transport.clone(),
926            },
927            control: ControlClient {
928                transport: transport.clone(),
929            },
930            transport,
931        })
932    }
933
934    pub fn set_api_key(&self, api_key: Option<String>) {
935        self.transport.set_api_key(api_key);
936    }
937
938    pub fn set_token(&self, token: Option<String>) {
939        self.set_api_key(token);
940    }
941
942    pub fn set_run_id(&self, run_id: Option<String>) {
943        self.transport.set_run_id(run_id);
944    }
945
946    pub fn set_transport(&self, transport: TransportMode) {
947        self.transport.set_transport(transport);
948    }
949}
950
951#[derive(Clone, Debug)]
952pub struct RememberOptions {
953    pub run_id: Option<String>,
954    pub agent_id: Option<String>,
955    pub item_id: Option<String>,
956    pub content: String,
957    pub content_type: String,
958    pub metadata: Option<Value>,
959    pub hints: Option<Value>,
960    pub payload: Option<Value>,
961    pub intent: Option<String>,
962    pub lesson_type: Option<String>,
963    pub lesson_scope: Option<String>,
964    pub lesson_importance: Option<String>,
965    pub lesson_conditions: Vec<String>,
966    pub user_id: Option<String>,
967    pub upsert_key: Option<String>,
968    pub importance: Option<String>,
969    pub source: Option<String>,
970    pub lane: Option<String>,
971    pub parallel: bool,
972    pub idempotency_key: Option<String>,
973    pub wait: bool,
974    pub timeout_ms: Option<u64>,
975    pub poll_interval_ms: u64,
976    /// When the event actually occurred (unix seconds). Distinct from ingestion
977    /// time. Used for temporal queries about when events happened vs when the
978    /// system learned about them.
979    pub occurrence_time: Option<i64>,
980}
981
982impl RememberOptions {
983    pub fn new(content: impl Into<String>) -> Self {
984        Self {
985            run_id: None,
986            agent_id: Some("sdk-client".to_string()),
987            item_id: None,
988            content: content.into(),
989            content_type: "text/plain".to_string(),
990            metadata: None,
991            hints: None,
992            payload: None,
993            intent: None,
994            lesson_type: None,
995            lesson_scope: None,
996            lesson_importance: None,
997            lesson_conditions: Vec::new(),
998            user_id: None,
999            upsert_key: None,
1000            importance: None,
1001            source: Some("agent".to_string()),
1002            lane: None,
1003            parallel: false,
1004            idempotency_key: None,
1005            wait: true,
1006            timeout_ms: None,
1007            poll_interval_ms: 300,
1008            occurrence_time: None,
1009        }
1010    }
1011}
1012
1013/// Phase 2.3: grouped lesson metadata. Replaces the flat `lesson_*`
1014/// quartet on `RememberOptions` so customers don't have to remember which
1015/// of twenty-odd parameters belong together. Used via
1016/// `RememberBuilder::lesson(LessonMeta { ... })`.
1017#[derive(Clone, Debug, Default)]
1018pub struct LessonMeta {
1019    pub lesson_type: Option<String>,
1020    pub lesson_scope: Option<String>,
1021    pub lesson_importance: Option<String>,
1022    pub lesson_conditions: Vec<String>,
1023}
1024
1025/// Phase 2.3: grouped session/agent/run scope. Caller passes one builder
1026/// method instead of three flat fields.
1027#[derive(Clone, Debug, Default)]
1028pub struct SessionScope {
1029    pub run_id: Option<String>,
1030    pub agent_id: Option<String>,
1031    pub user_id: Option<String>,
1032}
1033
1034/// Phase 2.3: fluent builder for `RememberOptions` — replaces the 23-param
1035/// flat construction with a grouped-chain API.
1036///
1037/// ```ignore
1038/// let opts = RememberBuilder::new("cache rebuild needs token rotation")
1039///     .lesson(LessonMeta {
1040///         lesson_type: Some("success".into()),
1041///         lesson_scope: Some("session".into()),
1042///         ..Default::default()
1043///     })
1044///     .session(SessionScope { run_id: Some(run_id), ..Default::default() })
1045///     .upsert("cache-rebuild-signer")
1046///     .build();
1047/// client.remember(opts).await?;
1048/// ```
1049pub struct RememberBuilder {
1050    options: RememberOptions,
1051}
1052
1053impl RememberBuilder {
1054    pub fn new(content: impl Into<String>) -> Self {
1055        Self {
1056            options: RememberOptions::new(content),
1057        }
1058    }
1059
1060    pub fn lesson(mut self, meta: LessonMeta) -> Self {
1061        self.options.lesson_type = meta.lesson_type;
1062        self.options.lesson_scope = meta.lesson_scope;
1063        self.options.lesson_importance = meta.lesson_importance;
1064        self.options.lesson_conditions = meta.lesson_conditions;
1065        self
1066    }
1067
1068    pub fn session(mut self, scope: SessionScope) -> Self {
1069        self.options.run_id = scope.run_id;
1070        self.options.agent_id = scope.agent_id.or(self.options.agent_id);
1071        self.options.user_id = scope.user_id;
1072        self
1073    }
1074
1075    pub fn metadata(mut self, metadata: Value) -> Self {
1076        self.options.metadata = Some(metadata);
1077        self
1078    }
1079
1080    pub fn hints(mut self, hints: Value) -> Self {
1081        self.options.hints = Some(hints);
1082        self
1083    }
1084
1085    pub fn payload(mut self, payload: Value) -> Self {
1086        self.options.payload = Some(payload);
1087        self
1088    }
1089
1090    pub fn upsert(mut self, key: impl Into<String>) -> Self {
1091        self.options.upsert_key = Some(key.into());
1092        self
1093    }
1094
1095    pub fn intent(mut self, intent: impl Into<String>) -> Self {
1096        self.options.intent = Some(intent.into());
1097        self
1098    }
1099
1100    pub fn lane(mut self, lane: impl Into<String>) -> Self {
1101        self.options.lane = Some(lane.into());
1102        self
1103    }
1104
1105    pub fn source(mut self, source: impl Into<String>) -> Self {
1106        self.options.source = Some(source.into());
1107        self
1108    }
1109
1110    pub fn importance(mut self, importance: impl Into<String>) -> Self {
1111        self.options.importance = Some(importance.into());
1112        self
1113    }
1114
1115    pub fn occurrence_time(mut self, ts_seconds: i64) -> Self {
1116        self.options.occurrence_time = Some(ts_seconds);
1117        self
1118    }
1119
1120    pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
1121        self.options.idempotency_key = Some(key.into());
1122        self
1123    }
1124
1125    pub fn wait(mut self, wait: bool) -> Self {
1126        self.options.wait = wait;
1127        self
1128    }
1129
1130    pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
1131        self.options.timeout_ms = Some(timeout_ms);
1132        self
1133    }
1134
1135    /// Finalise and hand back the underlying `RememberOptions`. Call
1136    /// `client.remember(builder.build()).await?` to submit.
1137    pub fn build(self) -> RememberOptions {
1138        self.options
1139    }
1140}
1141
1142#[derive(Clone, Debug)]
1143pub struct RecallOptions {
1144    pub run_id: Option<String>,
1145    pub query: String,
1146    pub schema: Option<String>,
1147    pub mode: String,
1148    pub direct_lane: String,
1149    pub include_linked_runs: bool,
1150    pub limit: u64,
1151    pub embedding: Vec<f32>,
1152    pub entry_types: Vec<String>,
1153    pub include_working_memory: bool,
1154    pub user_id: Option<String>,
1155    pub agent_id: Option<String>,
1156    pub lane: Option<String>,
1157    /// Temporal range filter lower bound (unix seconds, inclusive).
1158    pub min_timestamp: Option<i64>,
1159    /// Temporal range filter upper bound (unix seconds, inclusive).
1160    pub max_timestamp: Option<i64>,
1161    /// Search budget tier: "low", "mid", "high".
1162    pub budget: Option<String>,
1163    /// Ranking strategy: "relevance" (default), "freshness", "balanced".
1164    pub rank_by: Option<String>,
1165    /// When true, evidence items include an `explain_info` block detailing score components.
1166    pub explain: Option<bool>,
1167    /// When true, only evidence from the current run is returned. Cross-run
1168    /// session-scoped lessons (lesson_overlay) are skipped. Defaults to
1169    /// `None` / `false` — cross-run lesson retrieval is the intended
1170    /// behavior for durable learning; opt into strict scoping here.
1171    pub prefer_current_run: Option<bool>,
1172}
1173
1174impl RecallOptions {
1175    pub fn new(query: impl Into<String>) -> Self {
1176        Self {
1177            run_id: None,
1178            query: query.into(),
1179            schema: None,
1180            // Proto enum value names are case-sensitive on the wire. HTTP is
1181            // tolerant; gRPC is not. The Rust SDK's gRPC path has additional
1182            // serde strictness issues (missing-field requirements on prost
1183            // types) that cause it to auto-fall-back to HTTP today — see
1184            // docs/sdk-rust-grpc-known-issues. Using uppercase defaults is the
1185            // minimum fix that keeps gRPC viable when the rest is sorted.
1186            mode: "AGENT_ROUTED".to_string(),
1187            direct_lane: "SEMANTIC_SEARCH".to_string(),
1188            include_linked_runs: false,
1189            limit: 5,
1190            embedding: Vec::new(),
1191            entry_types: Vec::new(),
1192            include_working_memory: true,
1193            user_id: None,
1194            agent_id: None,
1195            lane: None,
1196            min_timestamp: None,
1197            max_timestamp: None,
1198            budget: None,
1199            rank_by: None,
1200            explain: None,
1201            prefer_current_run: None,
1202        }
1203    }
1204}
1205
1206#[derive(Clone, Debug)]
1207pub struct GetContextOptions {
1208    pub run_id: Option<String>,
1209    pub query: Option<String>,
1210    pub user_id: Option<String>,
1211    pub entry_types: Vec<String>,
1212    pub include_working_memory: bool,
1213    pub format: Option<String>,
1214    pub limit: Option<u64>,
1215    pub max_token_budget: Option<u32>,
1216    pub agent_id: Option<String>,
1217    pub mode: Option<String>,
1218    pub sections: Vec<String>,
1219    pub lane: Option<String>,
1220}
1221
1222impl Default for GetContextOptions {
1223    fn default() -> Self {
1224        Self {
1225            run_id: None,
1226            query: None,
1227            user_id: None,
1228            entry_types: Vec::new(),
1229            include_working_memory: true,
1230            format: None,
1231            limit: None,
1232            max_token_budget: None,
1233            agent_id: None,
1234            mode: None,
1235            sections: Vec::new(),
1236            lane: None,
1237        }
1238    }
1239}
1240
1241#[derive(Clone, Debug)]
1242pub struct ArchiveOptions {
1243    pub run_id: Option<String>,
1244    pub content: String,
1245    pub artifact_kind: String,
1246    pub metadata: Option<Value>,
1247    pub user_id: Option<String>,
1248    pub agent_id: Option<String>,
1249    pub origin_agent_id: Option<String>,
1250    pub source_attempt_id: Option<String>,
1251    pub source_tool: Option<String>,
1252    pub labels: Vec<String>,
1253    pub family: Option<String>,
1254    pub importance: Option<String>,
1255}
1256
1257impl ArchiveOptions {
1258    pub fn new(content: impl Into<String>, artifact_kind: impl Into<String>) -> Self {
1259        Self {
1260            run_id: None,
1261            content: content.into(),
1262            artifact_kind: artifact_kind.into(),
1263            metadata: None,
1264            user_id: None,
1265            agent_id: None,
1266            origin_agent_id: None,
1267            source_attempt_id: None,
1268            source_tool: None,
1269            labels: Vec::new(),
1270            family: None,
1271            importance: None,
1272        }
1273    }
1274}
1275
1276/// Phase 2.3: grouped artefact provenance. Replaces the five-parameter
1277/// (origin_agent_id / source_attempt_id / source_tool / labels / family)
1278/// cluster on `ArchiveOptions`.
1279#[derive(Clone, Debug, Default)]
1280pub struct ArtifactProvenance {
1281    pub origin_agent_id: Option<String>,
1282    pub source_attempt_id: Option<String>,
1283    pub source_tool: Option<String>,
1284    pub labels: Vec<String>,
1285    pub family: Option<String>,
1286}
1287
1288/// Phase 2.3: fluent builder for `ArchiveOptions` — replaces the 16-param
1289/// flat construction with a grouped-chain API.
1290///
1291/// ```ignore
1292/// let archive = ArchiveBuilder::new("report.md contents", "report")
1293///     .session(SessionScope { run_id: Some(run_id.clone()), ..Default::default() })
1294///     .provenance(ArtifactProvenance {
1295///         origin_agent_id: Some("writer".into()),
1296///         labels: vec!["monthly".into()],
1297///         ..Default::default()
1298///     })
1299///     .importance("high")
1300///     .build();
1301/// client.archive(archive).await?;
1302/// ```
1303pub struct ArchiveBuilder {
1304    options: ArchiveOptions,
1305}
1306
1307impl ArchiveBuilder {
1308    pub fn new(content: impl Into<String>, artifact_kind: impl Into<String>) -> Self {
1309        Self {
1310            options: ArchiveOptions::new(content, artifact_kind),
1311        }
1312    }
1313
1314    pub fn session(mut self, scope: SessionScope) -> Self {
1315        self.options.run_id = scope.run_id;
1316        self.options.agent_id = scope.agent_id;
1317        self.options.user_id = scope.user_id;
1318        self
1319    }
1320
1321    pub fn provenance(mut self, prov: ArtifactProvenance) -> Self {
1322        self.options.origin_agent_id = prov.origin_agent_id;
1323        self.options.source_attempt_id = prov.source_attempt_id;
1324        self.options.source_tool = prov.source_tool;
1325        self.options.labels = prov.labels;
1326        self.options.family = prov.family;
1327        self
1328    }
1329
1330    pub fn metadata(mut self, metadata: Value) -> Self {
1331        self.options.metadata = Some(metadata);
1332        self
1333    }
1334
1335    pub fn importance(mut self, importance: impl Into<String>) -> Self {
1336        self.options.importance = Some(importance.into());
1337        self
1338    }
1339
1340    pub fn label(mut self, label: impl Into<String>) -> Self {
1341        self.options.labels.push(label.into());
1342        self
1343    }
1344
1345    pub fn build(self) -> ArchiveOptions {
1346        self.options
1347    }
1348}
1349
1350#[derive(Clone, Debug)]
1351pub struct DereferenceOptions {
1352    pub run_id: Option<String>,
1353    pub reference_id: String,
1354    pub user_id: Option<String>,
1355    pub agent_id: Option<String>,
1356}
1357
1358impl DereferenceOptions {
1359    pub fn new(reference_id: impl Into<String>) -> Self {
1360        Self {
1361            run_id: None,
1362            reference_id: reference_id.into(),
1363            user_id: None,
1364            agent_id: None,
1365        }
1366    }
1367}
1368
1369#[derive(Clone, Debug)]
1370pub struct MemoryHealthOptions {
1371    pub run_id: Option<String>,
1372    pub user_id: Option<String>,
1373    pub stale_threshold_days: u32,
1374    pub limit: u32,
1375}
1376
1377impl Default for MemoryHealthOptions {
1378    fn default() -> Self {
1379        Self {
1380            run_id: None,
1381            user_id: None,
1382            stale_threshold_days: 30,
1383            limit: 500,
1384        }
1385    }
1386}
1387
1388#[derive(Clone, Debug)]
1389pub struct DiagnoseOptions {
1390    pub run_id: Option<String>,
1391    pub error_text: String,
1392    pub error_type: Option<String>,
1393    pub limit: u64,
1394    pub user_id: Option<String>,
1395}
1396
1397impl DiagnoseOptions {
1398    pub fn new(error_text: impl Into<String>) -> Self {
1399        Self {
1400            run_id: None,
1401            error_text: error_text.into(),
1402            error_type: None,
1403            limit: 10,
1404            user_id: None,
1405        }
1406    }
1407}
1408
1409#[derive(Clone, Debug, Default)]
1410pub struct ReflectOptions {
1411    pub run_id: Option<String>,
1412    pub include_linked_runs: bool,
1413    pub user_id: Option<String>,
1414    pub step_id: Option<String>,
1415    pub checkpoint_id: Option<String>,
1416    pub last_n_items: Option<u64>,
1417    pub include_step_outcomes: Option<bool>,
1418}
1419
1420#[derive(Clone, Debug, Default)]
1421pub struct ForgetOptions {
1422    pub run_id: Option<String>,
1423    pub lesson_id: Option<String>,
1424}
1425
1426impl ForgetOptions {
1427    pub fn for_run(run_id: impl Into<String>) -> Self {
1428        Self {
1429            run_id: Some(run_id.into()),
1430            lesson_id: None,
1431        }
1432    }
1433
1434    pub fn for_lesson(lesson_id: impl Into<String>) -> Self {
1435        Self {
1436            run_id: None,
1437            lesson_id: Some(lesson_id.into()),
1438        }
1439    }
1440}
1441
1442#[derive(Clone, Debug)]
1443pub struct CheckpointOptions {
1444    pub run_id: Option<String>,
1445    pub label: Option<String>,
1446    pub context_snapshot: String,
1447    pub metadata: Option<Value>,
1448    pub user_id: Option<String>,
1449    pub agent_id: Option<String>,
1450}
1451
1452impl CheckpointOptions {
1453    pub fn new(context_snapshot: impl Into<String>) -> Self {
1454        Self {
1455            run_id: None,
1456            label: None,
1457            context_snapshot: context_snapshot.into(),
1458            metadata: None,
1459            user_id: None,
1460            agent_id: None,
1461        }
1462    }
1463}
1464
1465#[derive(Clone, Debug)]
1466pub struct RegisterAgentOptions {
1467    pub run_id: Option<String>,
1468    pub agent_id: String,
1469    pub role: String,
1470    pub capabilities: Vec<String>,
1471    pub status: String,
1472    pub read_scopes: Vec<String>,
1473    pub write_scopes: Vec<String>,
1474    pub shared_memory_lanes: Vec<String>,
1475}
1476
1477impl RegisterAgentOptions {
1478    pub fn new(agent_id: impl Into<String>) -> Self {
1479        Self {
1480            run_id: None,
1481            agent_id: agent_id.into(),
1482            role: String::new(),
1483            capabilities: Vec::new(),
1484            status: "active".to_string(),
1485            read_scopes: Vec::new(),
1486            write_scopes: Vec::new(),
1487            shared_memory_lanes: Vec::new(),
1488        }
1489    }
1490}
1491
1492#[derive(Clone, Debug, Default)]
1493pub struct ListAgentsOptions {
1494    pub run_id: Option<String>,
1495}
1496
1497#[derive(Clone, Debug)]
1498pub struct RecordOutcomeOptions {
1499    pub run_id: Option<String>,
1500    pub reference_id: String,
1501    pub outcome: String,
1502    pub signal: f32,
1503    pub rationale: String,
1504    pub agent_id: Option<String>,
1505    pub user_id: Option<String>,
1506}
1507
1508impl RecordOutcomeOptions {
1509    pub fn new(reference_id: impl Into<String>, outcome: impl Into<String>) -> Self {
1510        Self {
1511            run_id: None,
1512            reference_id: reference_id.into(),
1513            outcome: outcome.into(),
1514            signal: 0.0,
1515            rationale: String::new(),
1516            agent_id: None,
1517            user_id: None,
1518        }
1519    }
1520}
1521
1522/// Phase 2.4: typed options for `optimize_prompt` — produces a challenger
1523/// prompt version that Phase 1.1's evaluator will surface as
1524/// `ready_for_promotion` once it accumulates enough outcomes. The high-level
1525/// wrapper on `Client` hides the raw `OptimizePromptRequest` shape and
1526/// defaults `auto_activate` to false (customers activate deliberately).
1527#[derive(Clone, Debug, Default)]
1528pub struct OptimizePromptOptions {
1529    pub agent_id: String,
1530    /// When true, the server activates the newly generated version
1531    /// immediately if its confidence exceeds the server-side threshold.
1532    /// Defaults to false so the champion/challenger evaluator drives
1533    /// activation instead.
1534    pub auto_activate: bool,
1535    pub run_id: Option<String>,
1536    /// Project scope. When present, optimisation ingests cross-agent lessons
1537    /// from the entire project.
1538    pub project_id: Option<String>,
1539}
1540
1541impl OptimizePromptOptions {
1542    pub fn new(agent_id: impl Into<String>) -> Self {
1543        Self {
1544            agent_id: agent_id.into(),
1545            auto_activate: false,
1546            run_id: None,
1547            project_id: None,
1548        }
1549    }
1550}
1551
1552/// Phase 2.4: typed options for `optimize_skill`.
1553#[derive(Clone, Debug, Default)]
1554pub struct OptimizeSkillOptions {
1555    pub skill_id: String,
1556    pub auto_activate: bool,
1557    pub project_id: Option<String>,
1558}
1559
1560impl OptimizeSkillOptions {
1561    pub fn new(skill_id: impl Into<String>) -> Self {
1562        Self {
1563            skill_id: skill_id.into(),
1564            auto_activate: false,
1565            project_id: None,
1566        }
1567    }
1568}
1569
1570/// Phase 1.5: options for `circuit_break`. The optional `reason` is recorded
1571/// on the snapshot lesson and the `CIRCUIT_BROKEN` event payload — pass a
1572/// short tag like "repeated_query" or "drift_detected" so later diagnosis is
1573/// easier.
1574#[derive(Clone, Debug, Default)]
1575pub struct CircuitBreakOptions {
1576    pub run_id: Option<String>,
1577    pub reason: Option<String>,
1578    pub agent_id: Option<String>,
1579}
1580
1581impl CircuitBreakOptions {
1582    pub fn new() -> Self {
1583        Self::default()
1584    }
1585    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
1586        self.reason = Some(reason.into());
1587        self
1588    }
1589}
1590
1591#[derive(Clone, Debug)]
1592pub struct RecordStepOutcomeOptions {
1593    pub run_id: Option<String>,
1594    pub step_id: String,
1595    pub step_name: Option<String>,
1596    pub outcome: String,
1597    pub signal: f32,
1598    pub rationale: String,
1599    pub directive_hint: Option<String>,
1600    pub agent_id: Option<String>,
1601    pub user_id: Option<String>,
1602    pub metadata: Option<Value>,
1603}
1604
1605impl RecordStepOutcomeOptions {
1606    pub fn new(step_id: impl Into<String>, outcome: impl Into<String>) -> Self {
1607        Self {
1608            run_id: None,
1609            step_id: step_id.into(),
1610            step_name: None,
1611            outcome: outcome.into(),
1612            signal: 0.0,
1613            rationale: String::new(),
1614            directive_hint: None,
1615            agent_id: None,
1616            user_id: None,
1617            metadata: None,
1618        }
1619    }
1620}
1621
1622#[derive(Clone, Debug)]
1623pub struct SurfaceStrategiesOptions {
1624    pub run_id: Option<String>,
1625    pub lesson_types: Vec<String>,
1626    pub max_strategies: u32,
1627    pub user_id: Option<String>,
1628}
1629
1630impl Default for SurfaceStrategiesOptions {
1631    fn default() -> Self {
1632        Self {
1633            run_id: None,
1634            lesson_types: Vec::new(),
1635            max_strategies: 5,
1636            user_id: None,
1637        }
1638    }
1639}
1640
1641#[derive(Clone, Debug)]
1642pub struct HandoffOptions {
1643    pub run_id: Option<String>,
1644    pub task_id: String,
1645    pub from_agent_id: String,
1646    pub to_agent_id: String,
1647    pub content: String,
1648    pub requested_action: String,
1649    pub metadata: Option<Value>,
1650    pub user_id: Option<String>,
1651}
1652
1653impl HandoffOptions {
1654    pub fn new(
1655        task_id: impl Into<String>,
1656        from_agent_id: impl Into<String>,
1657        to_agent_id: impl Into<String>,
1658        content: impl Into<String>,
1659    ) -> Self {
1660        Self {
1661            run_id: None,
1662            task_id: task_id.into(),
1663            from_agent_id: from_agent_id.into(),
1664            to_agent_id: to_agent_id.into(),
1665            content: content.into(),
1666            requested_action: "continue".to_string(),
1667            metadata: None,
1668            user_id: None,
1669        }
1670    }
1671}
1672
1673#[derive(Clone, Debug)]
1674pub struct FeedbackOptions {
1675    pub run_id: Option<String>,
1676    pub handoff_id: String,
1677    pub verdict: String,
1678    pub comments: String,
1679    pub from_agent_id: Option<String>,
1680    pub metadata: Option<Value>,
1681    pub user_id: Option<String>,
1682}
1683
1684impl FeedbackOptions {
1685    pub fn new(handoff_id: impl Into<String>, verdict: impl Into<String>) -> Self {
1686        Self {
1687            run_id: None,
1688            handoff_id: handoff_id.into(),
1689            verdict: verdict.into(),
1690            comments: String::new(),
1691            from_agent_id: None,
1692            metadata: None,
1693            user_id: None,
1694        }
1695    }
1696}
1697
1698impl Client {
1699    pub async fn remember(&self, options: RememberOptions) -> Result<Value> {
1700        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "remember")?;
1701        let content = require_non_empty_string(options.content, "content")?;
1702        let item_id = options
1703            .item_id
1704            .unwrap_or_else(|| generate_helper_id("remember"));
1705        let accepted = self
1706            .control
1707            .ingest(prune_nulls(json!({
1708                "run_id": run_id,
1709                "agent_id": options.agent_id.unwrap_or_else(|| "sdk-client".to_string()),
1710                "idempotency_key": options.idempotency_key.unwrap_or_else(|| item_id.clone()),
1711                "parallel": options.parallel,
1712                "items": [{
1713                    "item_id": item_id,
1714                    "content_type": options.content_type,
1715                    "text": content,
1716                    "payload_json": encode_optional_json(options.payload.as_ref())?,
1717                    "hints_json": encode_optional_json(options.hints.as_ref())?,
1718                    "metadata_json": encode_optional_json(options.metadata.as_ref())?,
1719                    "intent": options.intent,
1720                    "lesson_type": options.lesson_type,
1721                    "lesson_scope": options.lesson_scope,
1722                    "lesson_importance": options.lesson_importance,
1723                    "lesson_conditions_json": encode_string_vec(&options.lesson_conditions)?,
1724                    "user_id": options.user_id,
1725                    "upsert_key": options.upsert_key,
1726                    "importance": options.importance,
1727                    "source": options.source.unwrap_or_else(|| "agent".to_string()),
1728                    "lane": options.lane,
1729                    "occurrence_time": options.occurrence_time.unwrap_or(0),
1730                }],
1731            })))
1732            .await?;
1733
1734        if !options.wait {
1735            return Ok(accepted);
1736        }
1737
1738        let Some(job_id) = accepted.get("job_id").and_then(|value| value.as_str()) else {
1739            return Ok(accepted);
1740        };
1741
1742        self.wait_for_ingest_job(
1743            &run_id,
1744            job_id,
1745            options
1746                .timeout_ms
1747                .unwrap_or_else(|| self.transport.timeout.as_millis() as u64),
1748            options.poll_interval_ms,
1749        )
1750        .await
1751    }
1752
1753    pub async fn recall(&self, options: RecallOptions) -> Result<Value> {
1754        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "recall")?;
1755        self.control
1756            .query(prune_nulls(json!({
1757                "run_id": run_id,
1758                "query": require_non_empty_string(options.query, "query")?,
1759                "schema": options.schema,
1760                "mode": options.mode,
1761                "direct_lane": options.direct_lane,
1762                "include_linked_runs": options.include_linked_runs,
1763                "limit": options.limit,
1764                "embedding": options.embedding,
1765                "entry_types": if options.entry_types.is_empty() { Value::Null } else { json!(options.entry_types) },
1766                "include_working_memory": options.include_working_memory,
1767                "user_id": options.user_id,
1768                "agent_id": options.agent_id,
1769                "lane": options.lane,
1770                "min_timestamp": options.min_timestamp.unwrap_or(0),
1771                "max_timestamp": options.max_timestamp.unwrap_or(0),
1772                "budget": options.budget,
1773                "rank_by": options.rank_by,
1774                "explain": options.explain,
1775                "prefer_current_run": options.prefer_current_run,
1776            })))
1777            .await
1778    }
1779
1780    pub async fn get_context(&self, options: GetContextOptions) -> Result<Value> {
1781        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "get_context")?;
1782        self.control
1783            .context(prune_nulls(json!({
1784                "run_id": run_id,
1785                "query": require_non_empty_string(options.query.unwrap_or_default(), "query")?,
1786                "user_id": options.user_id,
1787                "entry_types": if options.entry_types.is_empty() { Value::Null } else { json!(options.entry_types) },
1788                "include_working_memory": options.include_working_memory,
1789                "format": options.format.unwrap_or_else(|| "structured".to_string()),
1790                "limit": options.limit.unwrap_or(5),
1791                "max_token_budget": options.max_token_budget.unwrap_or(0),
1792                "agent_id": options.agent_id,
1793                "mode": options.mode.unwrap_or_else(|| "full".to_string()),
1794                "sections": if options.sections.is_empty() { Value::Null } else { json!(options.sections) },
1795                "lane": options.lane,
1796            })))
1797            .await
1798    }
1799
1800    pub async fn archive(&self, options: ArchiveOptions) -> Result<Value> {
1801        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "archive")?;
1802        let content = require_non_empty_string(options.content, "content")?;
1803        let artifact_kind = require_non_empty_string(options.artifact_kind, "artifact_kind")?;
1804        let agent_id = options.agent_id.clone();
1805        self.control
1806            .archive_block(prune_nulls(json!({
1807                "run_id": run_id,
1808                "content": content,
1809                "artifact_kind": artifact_kind,
1810                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
1811                "user_id": options.user_id,
1812                "agent_id": agent_id.clone(),
1813                "origin_agent_id": options.origin_agent_id.or(agent_id),
1814                "source_attempt_id": options.source_attempt_id,
1815                "source_tool": options.source_tool,
1816                "labels": if options.labels.is_empty() { Value::Null } else { json!(options.labels) },
1817                "family": options.family,
1818                "importance": options.importance,
1819            })))
1820            .await
1821    }
1822
1823    pub async fn archive_block(&self, options: ArchiveOptions) -> Result<Value> {
1824        self.archive(options).await
1825    }
1826
1827    pub async fn dereference(&self, options: DereferenceOptions) -> Result<Value> {
1828        let run_id =
1829            resolve_helper_run_id(options.run_id, self.transport.run_id(), "dereference")?;
1830        self.control
1831            .dereference(prune_nulls(json!({
1832                "run_id": run_id,
1833                "reference_id": require_non_empty_string(options.reference_id, "reference_id")?,
1834                "user_id": options.user_id,
1835                "agent_id": options.agent_id,
1836            })))
1837            .await
1838    }
1839
1840    pub async fn memory_health(&self, options: MemoryHealthOptions) -> Result<Value> {
1841        let run_id =
1842            resolve_helper_run_id(options.run_id, self.transport.run_id(), "memory_health")?;
1843        self.control
1844            .memory_health(prune_nulls(json!({
1845                "run_id": run_id,
1846                "user_id": options.user_id,
1847                "stale_threshold_days": options.stale_threshold_days,
1848                "limit": options.limit,
1849            })))
1850            .await
1851    }
1852
1853    pub async fn diagnose(&self, options: DiagnoseOptions) -> Result<Value> {
1854        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "diagnose")?;
1855        self.control
1856            .diagnose(prune_nulls(json!({
1857                "run_id": run_id,
1858                "error_text": require_non_empty_string(options.error_text, "error_text")?,
1859                "error_type": options.error_type,
1860                "limit": options.limit,
1861                "user_id": options.user_id,
1862            })))
1863            .await
1864    }
1865
1866    pub async fn reflect(&self, options: ReflectOptions) -> Result<Value> {
1867        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "reflect")?;
1868        self.control
1869            .reflect(prune_nulls(json!({
1870                "run_id": run_id,
1871                "include_linked_runs": options.include_linked_runs,
1872                "user_id": options.user_id,
1873                "step_id": options.step_id,
1874                "checkpoint_id": options.checkpoint_id,
1875                "last_n_items": options.last_n_items,
1876                "include_step_outcomes": options.include_step_outcomes,
1877            })))
1878            .await
1879    }
1880
1881    pub async fn forget(&self, options: ForgetOptions) -> Result<Value> {
1882        let delete_lesson = options
1883            .lesson_id
1884            .as_ref()
1885            .map(|value| !value.trim().is_empty())
1886            .unwrap_or(false);
1887        let run_id = if options.run_id.is_some() {
1888            options.run_id
1889        } else if delete_lesson {
1890            None
1891        } else {
1892            self.transport.run_id()
1893        };
1894        let delete_run = run_id
1895            .as_ref()
1896            .map(|value| !value.trim().is_empty())
1897            .unwrap_or(false);
1898
1899        if (delete_lesson as u8) + (delete_run as u8) != 1 {
1900            return Err(SdkError::ValidationError(
1901                "forget requires either lesson_id or run_id, but not both".to_string(),
1902            ));
1903        }
1904
1905        if delete_lesson {
1906            return self
1907                .control
1908                .delete_lesson(json!({ "lesson_id": options.lesson_id.unwrap_or_default() }))
1909                .await;
1910        }
1911
1912        self.control
1913            .delete_run(json!({ "run_id": run_id.unwrap_or_default() }))
1914            .await
1915    }
1916
1917    pub async fn checkpoint(&self, options: CheckpointOptions) -> Result<Value> {
1918        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "checkpoint")?;
1919        self.control
1920            .checkpoint(prune_nulls(json!({
1921                "run_id": run_id,
1922                "label": options.label,
1923                "context_snapshot": require_non_empty_string(options.context_snapshot, "context_snapshot")?,
1924                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
1925                "user_id": options.user_id,
1926                "agent_id": options.agent_id,
1927            })))
1928            .await
1929    }
1930
1931    pub async fn register_agent(&self, options: RegisterAgentOptions) -> Result<Value> {
1932        let run_id =
1933            resolve_helper_run_id(options.run_id, self.transport.run_id(), "register_agent")?;
1934        self.control
1935            .register_agent(prune_nulls(json!({
1936                "run_id": run_id,
1937                "agent_id": require_non_empty_string(options.agent_id, "agent_id")?,
1938                "role": options.role,
1939                "capabilities": if options.capabilities.is_empty() { Value::Null } else { json!(options.capabilities) },
1940                "status": options.status,
1941                "read_scopes": if options.read_scopes.is_empty() { Value::Null } else { json!(options.read_scopes) },
1942                "write_scopes": if options.write_scopes.is_empty() { Value::Null } else { json!(options.write_scopes) },
1943                "shared_memory_lanes": if options.shared_memory_lanes.is_empty() { Value::Null } else { json!(options.shared_memory_lanes) },
1944            })))
1945            .await
1946    }
1947
1948    pub async fn list_agents(&self, options: ListAgentsOptions) -> Result<Value> {
1949        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "list_agents")?;
1950        self.control.list_agents(json!({ "run_id": run_id })).await
1951    }
1952
1953    pub async fn record_outcome(&self, options: RecordOutcomeOptions) -> Result<Value> {
1954        let run_id =
1955            resolve_helper_run_id(options.run_id, self.transport.run_id(), "record_outcome")?;
1956        self.control
1957            .record_outcome(prune_nulls(json!({
1958                "run_id": run_id,
1959                "reference_id": require_non_empty_string(options.reference_id, "reference_id")?,
1960                "outcome": require_non_empty_string(options.outcome, "outcome")?,
1961                "signal": options.signal,
1962                "rationale": options.rationale,
1963                "agent_id": options.agent_id,
1964                "user_id": options.user_id,
1965            })))
1966            .await
1967    }
1968
1969    /// Phase 2.4: generate a challenger prompt version from accumulated
1970    /// lessons and outcome stats. The server never auto-activates unless
1971    /// `auto_activate=true` explicitly; by default the challenger surfaces
1972    /// via the champion/challenger evaluator (Phase 1.1) with
1973    /// `ready_for_promotion=true` once it outperforms the active version.
1974    pub async fn optimize_prompt(&self, options: OptimizePromptOptions) -> Result<Value> {
1975        let run_id = options
1976            .run_id
1977            .clone()
1978            .or_else(|| self.transport.run_id())
1979            .unwrap_or_default();
1980        self.control
1981            .optimize_prompt(prune_nulls(json!({
1982                "agent_id": require_non_empty_string(options.agent_id, "agent_id")?,
1983                "auto_activate": options.auto_activate,
1984                "run_id": if run_id.is_empty() { None } else { Some(run_id) },
1985                "project_id": options.project_id,
1986            })))
1987            .await
1988    }
1989
1990    /// Phase 2.4: skill-version analogue of `optimize_prompt`.
1991    pub async fn optimize_skill(&self, options: OptimizeSkillOptions) -> Result<Value> {
1992        self.control
1993            .optimize_skill(prune_nulls(json!({
1994                "skill_id": require_non_empty_string(options.skill_id, "skill_id")?,
1995                "auto_activate": options.auto_activate,
1996                "project_id": options.project_id,
1997            })))
1998            .await
1999    }
2000
2001    /// Phase 1.5: atomic anti-loop reset. Snapshots working memory to LTM as
2002    /// a `loop_detected` lesson, clears working memory, resets the drift
2003    /// monitor state, and emits a `CIRCUIT_BROKEN` event. Customers should
2004    /// invoke this after observing `signals.repeated == true` (or stagnant /
2005    /// drifting) on a recall response.
2006    pub async fn circuit_break(&self, options: CircuitBreakOptions) -> Result<Value> {
2007        let run_id =
2008            resolve_helper_run_id(options.run_id, self.transport.run_id(), "circuit_break")?;
2009        self.control
2010            .circuit_break(prune_nulls(json!({
2011                "run_id": run_id,
2012                "reason": options.reason,
2013                "agent_id": options.agent_id,
2014            })))
2015            .await
2016    }
2017
2018    pub async fn record_step_outcome(&self, options: RecordStepOutcomeOptions) -> Result<Value> {
2019        let run_id =
2020            resolve_helper_run_id(options.run_id, self.transport.run_id(), "record_step_outcome")?;
2021        self.control
2022            .record_outcome(prune_nulls(json!({
2023                "run_id": run_id,
2024                "step_id": require_non_empty_string(options.step_id, "step_id")?,
2025                "step_name": options.step_name,
2026                "outcome": require_non_empty_string(options.outcome, "outcome")?,
2027                "signal": options.signal,
2028                "rationale": options.rationale,
2029                "directive_hint": options.directive_hint,
2030                "agent_id": options.agent_id,
2031                "user_id": options.user_id,
2032                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2033            })))
2034            .await
2035    }
2036
2037    pub async fn surface_strategies(&self, options: SurfaceStrategiesOptions) -> Result<Value> {
2038        let run_id = resolve_helper_run_id(
2039            options.run_id,
2040            self.transport.run_id(),
2041            "surface_strategies",
2042        )?;
2043        self.control
2044            .surface_strategies(prune_nulls(json!({
2045                "run_id": run_id,
2046                "lesson_types": if options.lesson_types.is_empty() { Value::Null } else { json!(options.lesson_types) },
2047                "max_strategies": options.max_strategies,
2048                "user_id": options.user_id,
2049            })))
2050            .await
2051    }
2052
2053    pub async fn handoff(&self, options: HandoffOptions) -> Result<Value> {
2054        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "handoff")?;
2055        self.control
2056            .create_handoff(prune_nulls(json!({
2057                "run_id": run_id,
2058                "task_id": require_non_empty_string(options.task_id, "task_id")?,
2059                "from_agent_id": require_non_empty_string(options.from_agent_id, "from_agent_id")?,
2060                "to_agent_id": require_non_empty_string(options.to_agent_id, "to_agent_id")?,
2061                "content": require_non_empty_string(options.content, "content")?,
2062                "requested_action": options.requested_action,
2063                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2064                "user_id": options.user_id,
2065            })))
2066            .await
2067    }
2068
2069    pub async fn feedback(&self, options: FeedbackOptions) -> Result<Value> {
2070        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "feedback")?;
2071        self.control
2072            .submit_feedback(prune_nulls(json!({
2073                "run_id": run_id,
2074                "handoff_id": require_non_empty_string(options.handoff_id, "handoff_id")?,
2075                "verdict": require_non_empty_string(options.verdict, "verdict")?,
2076                "comments": options.comments,
2077                "from_agent_id": options.from_agent_id,
2078                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2079                "user_id": options.user_id,
2080            })))
2081            .await
2082    }
2083
2084    async fn wait_for_ingest_job(
2085        &self,
2086        run_id: &str,
2087        job_id: &str,
2088        timeout_ms: u64,
2089        poll_interval_ms: u64,
2090    ) -> Result<Value> {
2091        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
2092        loop {
2093            let job = self
2094                .control
2095                .get_ingest_job(json!({ "run_id": run_id, "job_id": job_id }))
2096                .await?;
2097            if job
2098                .get("done")
2099                .and_then(|value| value.as_bool())
2100                .unwrap_or(false)
2101            {
2102                return Ok(job);
2103            }
2104            if Instant::now() >= deadline {
2105                return Err(SdkError::TransportError {
2106                    kind: TransportFailureKind::DeadlineExceeded,
2107                    message: format!("timed out waiting for ingest job {}", job_id),
2108                });
2109            }
2110            sleep(Duration::from_millis(poll_interval_ms)).await;
2111        }
2112    }
2113}
2114
2115#[derive(Clone)]
2116pub struct AuthClient {
2117    transport: Arc<TransportEngine>,
2118}
2119
2120impl AuthClient {
2121    pub async fn health(&self) -> Result<Value> {
2122        self.transport.invoke("auth.health", json!({})).await
2123    }
2124
2125    pub fn set_api_key(&self, api_key: Option<String>) {
2126        self.transport.set_api_key(api_key);
2127    }
2128
2129    pub fn set_token(&self, token: Option<String>) {
2130        self.set_api_key(token);
2131    }
2132
2133    pub fn set_run_id(&self, run_id: Option<String>) {
2134        self.transport.set_run_id(run_id);
2135    }
2136}
2137
2138macro_rules! define_auth_payload_methods {
2139    ($($name:ident => $op_key:literal),+ $(,)?) => {
2140        impl AuthClient {
2141            $(
2142                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2143                    self.transport.invoke_serialized($op_key, payload).await
2144                }
2145            )+
2146        }
2147    };
2148}
2149
2150define_auth_payload_methods!(
2151    create_user => "auth.create_user",
2152    rotate_user_api_key => "auth.rotate_user_api_key",
2153    revoke_user_api_key => "auth.revoke_user_api_key",
2154    list_users => "auth.list_users",
2155    get_user => "auth.get_user",
2156    delete_user => "auth.delete_user"
2157);
2158
2159#[derive(Clone)]
2160pub struct CoreClient {
2161    transport: Arc<TransportEngine>,
2162}
2163
2164macro_rules! define_core_payload_methods {
2165    ($($name:ident => $op_key:literal),+ $(,)?) => {
2166        impl CoreClient {
2167            $(
2168                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2169                    self.transport.invoke_serialized($op_key, payload).await
2170                }
2171            )+
2172        }
2173    };
2174}
2175
2176define_core_payload_methods!(
2177    insert => "core.insert",
2178    batch_insert => "core.batch_insert",
2179    search => "core.search",
2180    delete_node => "core.delete_node",
2181    delete_run => "core.delete_run",
2182    create_session => "core.create_session",
2183    snapshot_session => "core.snapshot_session",
2184    load_session => "core.load_session",
2185    commit_session => "core.commit_session",
2186    drop_session => "core.drop_session",
2187    write_memory => "core.write_memory",
2188    read_memory => "core.read_memory",
2189    add_memory => "core.add_memory",
2190    get_memory => "core.get_memory",
2191    clear_memory => "core.clear_memory",
2192    grant_permission => "core.grant_permission",
2193    revoke_permission => "core.revoke_permission",
2194    check_permission => "core.check_permission",
2195    unsubscribe_events => "core.unsubscribe_events"
2196);
2197
2198impl CoreClient {
2199    pub async fn subscribe_events<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2200        self.transport.invoke_stream_serialized("core.subscribe_events", payload).await
2201    }
2202
2203    pub async fn watch_memory<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2204        self.transport.invoke_stream_serialized("core.watch_memory", payload).await
2205    }
2206
2207    pub async fn list_subscriptions(&self) -> Result<Value> {
2208        self.transport
2209            .invoke("core.list_subscriptions", json!({}))
2210            .await
2211    }
2212
2213    pub async fn storage_stats(&self) -> Result<Value> {
2214        self.transport.invoke("core.storage_stats", json!({})).await
2215    }
2216
2217    pub async fn trigger_compaction(&self) -> Result<Value> {
2218        self.transport
2219            .invoke("core.trigger_compaction", json!({}))
2220            .await
2221    }
2222}
2223
2224#[derive(Clone)]
2225pub struct ControlClient {
2226    transport: Arc<TransportEngine>,
2227}
2228
2229macro_rules! define_control_payload_methods {
2230    ($($name:ident => $op_key:literal),+ $(,)?) => {
2231        impl ControlClient {
2232            $(
2233                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2234                    self.transport.invoke_serialized($op_key, payload).await
2235                }
2236            )+
2237        }
2238    };
2239}
2240
2241define_control_payload_methods!(
2242    register_agent => "control.register_agent",
2243    agent_heartbeat => "control.agent_heartbeat",
2244    context_snapshot => "control.context_snapshot",
2245    link_run => "control.link_run",
2246    unlink_run => "control.unlink_run",
2247    ingest => "control.ingest",
2248    batch_insert => "control.batch_insert",
2249    get_ingest_job => "control.get_ingest_job",
2250    get_run_ingest_stats => "control.get_run_ingest_stats",
2251    query => "control.query",
2252    diagnose => "control.diagnose",
2253    delete_run => "control.delete_run",
2254    reflect => "control.reflect",
2255    lessons => "control.lessons",
2256    delete_lesson => "control.delete_lesson",
2257    context => "control.context",
2258    archive_block => "control.archive_block",
2259    dereference => "control.dereference",
2260    memory_health => "control.memory_health",
2261    checkpoint => "control.checkpoint",
2262    list_agents => "control.list_agents",
2263    create_handoff => "control.create_handoff",
2264    submit_feedback => "control.submit_feedback",
2265    circuit_break => "control.circuit_break",
2266    record_outcome => "control.record_outcome",
2267    record_step_outcome => "control.record_step_outcome",
2268    surface_strategies => "control.surface_strategies",
2269    create_session => "control.create_session",
2270    get_session => "control.get_session",
2271    close_session => "control.close_session",
2272    set_prompt => "control.set_prompt",
2273    get_prompt => "control.get_prompt",
2274    list_prompt_versions => "control.list_prompt_versions",
2275    activate_prompt_version => "control.activate_prompt_version",
2276    optimize_prompt => "control.optimize_prompt",
2277    get_prompt_diff => "control.get_prompt_diff",
2278    create_project => "control.create_project",
2279    get_project => "control.get_project",
2280    list_projects => "control.list_projects",
2281    update_project => "control.update_project",
2282    delete_project => "control.delete_project",
2283    create_agent_definition => "control.create_agent_definition",
2284    get_agent_definition => "control.get_agent_definition",
2285    list_agent_definitions => "control.list_agent_definitions",
2286    update_agent_definition => "control.update_agent_definition",
2287    delete_agent_definition => "control.delete_agent_definition",
2288    list_run_history => "control.list_run_history",
2289    get_run_history => "control.get_run_history",
2290    create_skill => "control.create_skill",
2291    get_skill => "control.get_skill",
2292    list_skills => "control.list_skills",
2293    update_skill => "control.update_skill",
2294    delete_skill => "control.delete_skill",
2295    list_skill_versions => "control.list_skill_versions",
2296    activate_skill_version => "control.activate_skill_version",
2297    optimize_skill => "control.optimize_skill",
2298    get_skill_diff => "control.get_skill_diff",
2299);
2300
2301macro_rules! define_client_control_delegates {
2302    ($($name:ident),+ $(,)?) => {
2303        impl Client {
2304            $(
2305                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2306                    self.control.$name(payload).await
2307                }
2308            )+
2309        }
2310    };
2311}
2312
2313define_client_control_delegates!(
2314    agent_heartbeat, context_snapshot,
2315    link_run, unlink_run,
2316    ingest, batch_insert, get_ingest_job, get_run_ingest_stats,
2317    query,
2318    delete_run,
2319    lessons, delete_lesson,
2320    context,
2321    create_handoff, submit_feedback,
2322    create_session, get_session, close_session,
2323    set_prompt, get_prompt, list_prompt_versions,
2324    activate_prompt_version, get_prompt_diff,
2325    create_project, get_project, list_projects, update_project, delete_project,
2326    create_agent_definition, get_agent_definition, list_agent_definitions,
2327    update_agent_definition, delete_agent_definition,
2328    list_run_history, get_run_history,
2329    create_skill, get_skill, list_skills, update_skill, delete_skill,
2330    list_skill_versions, activate_skill_version, get_skill_diff,
2331);
2332
2333impl Client {
2334    pub async fn subscribe<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2335        self.control.subscribe(payload).await
2336    }
2337}
2338
2339impl ControlClient {
2340    pub async fn subscribe<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2341        self.transport.invoke_stream_serialized("control.subscribe", payload).await
2342    }
2343}
2344
2345fn parse_sse_byte_stream(
2346    byte_stream: impl Stream<Item = reqwest::Result<bytes::Bytes>> + Send + 'static,
2347) -> impl Stream<Item = Result<Value>> + Send {
2348    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Value>>(64);
2349    tokio::spawn(async move {
2350        tokio::pin!(byte_stream);
2351        let mut buffer = String::new();
2352        while let Some(chunk_result) = byte_stream.next().await {
2353            match chunk_result {
2354                Ok(chunk) => {
2355                    buffer.push_str(&String::from_utf8_lossy(&chunk));
2356                    while let Some(newline_pos) = buffer.find('\n') {
2357                        let line = buffer[..newline_pos].trim().to_string();
2358                        buffer = buffer[newline_pos + 1..].to_string();
2359                        if line.is_empty() || line.starts_with(':') {
2360                            continue;
2361                        }
2362                        let data = if line.starts_with("data: ") {
2363                            &line[6..]
2364                        } else {
2365                            &line
2366                        };
2367                        let value = match serde_json::from_str::<Value>(data) {
2368                            Ok(value) => Ok(value),
2369                            Err(_) => Ok(Value::String(data.to_string())),
2370                        };
2371                        if tx.send(value).await.is_err() {
2372                            return;
2373                        }
2374                    }
2375                }
2376                Err(e) => {
2377                    let _ = tx
2378                        .send(Err(SdkError::TransportError {
2379                            kind: TransportFailureKind::Io,
2380                            message: e.to_string(),
2381                        }))
2382                        .await;
2383                    return;
2384                }
2385            }
2386        }
2387        // Flush remaining buffer
2388        let remaining = buffer.trim().to_string();
2389        if !remaining.is_empty() && !remaining.starts_with(':') {
2390            let data = if remaining.starts_with("data: ") {
2391                &remaining[6..]
2392            } else {
2393                &remaining
2394            };
2395            let value = match serde_json::from_str::<Value>(data) {
2396                Ok(value) => Ok(value),
2397                Err(_) => Ok(Value::String(data.to_string())),
2398            };
2399            let _ = tx.send(value).await;
2400        }
2401    });
2402    tokio_stream::wrappers::ReceiverStream::new(rx)
2403}
2404
2405/// Parse `metadata_json` / `entry_json` strings on a pubsub event dict into
2406/// native `metadata` / `entry` objects, and drop proto3 default-value fields
2407/// that aren't meaningful for the event kind (e.g. `created_at: 0` on a
2408/// `node.deleted` or `subscribed` event). The server sends JSON-encoded
2409/// strings to keep the proto flat; this is the symmetric un-packer so every
2410/// SDK caller sees one native shape regardless of transport.
2411fn hydrate_pubsub_event(value: &mut Value) {
2412    let Some(obj) = value.as_object_mut() else {
2413        return;
2414    };
2415
2416    if let Some(Value::String(raw)) = obj.remove("metadata_json") {
2417        let parsed = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
2418        obj.insert("metadata".to_string(), parsed);
2419    }
2420
2421    if let Some(Value::String(raw)) = obj.remove("entry_json") {
2422        let parsed = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
2423        obj.insert("entry".to_string(), parsed);
2424    }
2425
2426    // Strip proto3 scalar defaults that only exist because the schema is flat.
2427    // The `type` discriminator tells the caller which fields are meaningful;
2428    // the rest just clutter the payload.
2429    let event_type = obj
2430        .get("type")
2431        .and_then(Value::as_str)
2432        .map(str::to_string)
2433        .unwrap_or_default();
2434    let keep: &[&str] = match event_type.as_str() {
2435        "subscribed" => &["type", "subscription_id"],
2436        "node.inserted" | "node.updated" => {
2437            &["type", "node_id", "run_id", "metadata", "created_at", "updated_at"]
2438        }
2439        "node.deleted" => &["type", "node_id"],
2440        "memory.added" => &["type", "session_id", "entry"],
2441        _ => return,
2442    };
2443    obj.retain(|k, _| keep.iter().any(|known| *known == k));
2444}
2445
2446fn prune_nulls(value: Value) -> Value {
2447    match value {
2448        Value::Object(map) => Value::Object(
2449            map.into_iter()
2450                .filter_map(|(key, value)| {
2451                    let cleaned = prune_nulls(value);
2452                    if cleaned.is_null() {
2453                        None
2454                    } else {
2455                        Some((key, cleaned))
2456                    }
2457                })
2458                .collect::<Map<String, Value>>(),
2459        ),
2460        Value::Array(items) => Value::Array(items.into_iter().map(prune_nulls).collect()),
2461        other => other,
2462    }
2463}
2464
2465fn resolve_helper_run_id(
2466    explicit: Option<String>,
2467    fallback: Option<String>,
2468    helper_name: &str,
2469) -> Result<String> {
2470    let candidate = explicit.or(fallback).unwrap_or_default();
2471    if candidate.trim().is_empty() {
2472        return Err(SdkError::ValidationError(format!(
2473            "{} requires run_id or a client default run_id",
2474            helper_name
2475        )));
2476    }
2477    Ok(candidate)
2478}
2479
2480fn require_non_empty_string(value: String, field_name: &str) -> Result<String> {
2481    if value.trim().is_empty() {
2482        return Err(SdkError::ValidationError(format!(
2483            "{} is required",
2484            field_name
2485        )));
2486    }
2487    Ok(value)
2488}
2489
2490fn encode_optional_json(value: Option<&Value>) -> Result<String> {
2491    match value {
2492        Some(value) => serde_json::to_string(value).map_err(|err| {
2493            SdkError::ValidationError(format!("failed to serialize helper json field: {}", err))
2494        }),
2495        None => Ok(String::new()),
2496    }
2497}
2498
2499fn encode_string_vec(values: &[String]) -> Result<String> {
2500    if values.is_empty() {
2501        return Ok(String::new());
2502    }
2503    serde_json::to_string(values).map_err(|err| {
2504        SdkError::ValidationError(format!("failed to serialize helper string list: {}", err))
2505    })
2506}
2507
2508fn generate_helper_id(prefix: &str) -> String {
2509    use std::time::{SystemTime, UNIX_EPOCH};
2510
2511    let millis = SystemTime::now()
2512        .duration_since(UNIX_EPOCH)
2513        .unwrap_or_default()
2514        .as_millis();
2515    format!("{}-{}", prefix, millis)
2516}
2517
2518fn ensure_object_payload(payload: Value) -> Result<Value> {
2519    match payload {
2520        Value::Null => Ok(json!({})),
2521        Value::Object(_) => Ok(payload),
2522        _ => Err(SdkError::ValidationError(
2523            "payload must serialize to a JSON object".to_string(),
2524        )),
2525    }
2526}
2527
2528fn decode_grpc_request<T: DeserializeOwned>(op_key: &str, payload: Value) -> Result<T> {
2529    serde_json::from_value(payload).map_err(|e| {
2530        SdkError::ValidationError(format!(
2531            "invalid gRPC request payload for {}: {}",
2532            op_key, e
2533        ))
2534    })
2535}
2536
2537fn encode_grpc_response<T: Serialize>(op_key: &str, response: T) -> Result<Value> {
2538    serde_json::to_value(response).map_err(|e| {
2539        SdkError::ServerError(format!(
2540            "failed to serialize gRPC response for {}: {}",
2541            op_key, e
2542        ))
2543    })
2544}
2545
2546fn decode_batch_insert_payload(
2547    payload: Value,
2548) -> Result<Vec<crate::proto::mubit::v1::InsertRequest>> {
2549    let payload = ensure_object_payload(payload)?;
2550    let mut extracted: Option<&Value> = None;
2551    if let Some(map) = payload.as_object() {
2552        for key in ["items", "requests", "nodes"] {
2553            if let Some(value) = map.get(key) {
2554                extracted = Some(value);
2555                break;
2556            }
2557        }
2558    }
2559
2560    if let Some(Value::Array(items)) = extracted {
2561        if items.is_empty() {
2562            return Err(SdkError::ValidationError(
2563                "batch_insert gRPC payload cannot provide an empty items list".to_string(),
2564            ));
2565        }
2566        return items
2567            .iter()
2568            .cloned()
2569            .map(|item| decode_grpc_request("core.batch_insert", item))
2570            .collect();
2571    }
2572
2573    if extracted.is_some() {
2574        return Err(SdkError::ValidationError(
2575            "batch_insert gRPC payload items/requests/nodes must be an array".to_string(),
2576        ));
2577    }
2578
2579    let single = decode_grpc_request("core.batch_insert", payload)?;
2580    Ok(vec![single])
2581}
2582
2583fn value_to_param(value: &Value) -> Option<String> {
2584    match value {
2585        Value::String(v) => Some(v.clone()),
2586        Value::Number(v) => Some(v.to_string()),
2587        Value::Bool(v) => Some(v.to_string()),
2588        _ => None,
2589    }
2590}
2591
2592fn value_to_query(value: &Value) -> Option<String> {
2593    match value {
2594        Value::String(v) => Some(v.clone()),
2595        Value::Number(v) => Some(v.to_string()),
2596        Value::Bool(v) => Some(v.to_string()),
2597        Value::Array(items) => {
2598            let rendered: Vec<String> = items.iter().filter_map(value_to_query).collect();
2599            if rendered.is_empty() {
2600                None
2601            } else {
2602                Some(rendered.join(","))
2603            }
2604        }
2605        _ => None,
2606    }
2607}
2608
2609fn derive_http_and_grpc(endpoint: &str) -> Result<(String, String, bool)> {
2610    let endpoint = if endpoint.contains("://") {
2611        endpoint.to_string()
2612    } else {
2613        format!("http://{}", endpoint)
2614    };
2615
2616    let parsed = Url::parse(&endpoint).map_err(|e| {
2617        SdkError::ValidationError(format!("invalid endpoint '{}': {}", endpoint, e))
2618    })?;
2619
2620    let host = parsed.host_str().ok_or_else(|| {
2621        SdkError::ValidationError(format!("endpoint '{}' missing host", endpoint))
2622    })?;
2623
2624    let scheme = parsed.scheme();
2625    let port = parsed.port_or_known_default().ok_or_else(|| {
2626        SdkError::ValidationError(format!(
2627            "endpoint '{}' missing known default port",
2628            endpoint
2629        ))
2630    })?;
2631
2632    let default_port = match scheme {
2633        "https" => 443,
2634        _ => 80,
2635    };
2636
2637    let http_endpoint = if port == default_port {
2638        format!("{}://{}", scheme, host)
2639    } else {
2640        format!("{}://{}:{}", scheme, host, port)
2641    };
2642
2643    let grpc_endpoint = format!("{}:{}", host, port);
2644    let grpc_tls = scheme.eq_ignore_ascii_case("https");
2645
2646    Ok((http_endpoint, grpc_endpoint, grpc_tls))
2647}
2648
2649fn normalize_http_endpoint(endpoint: &str) -> Result<String> {
2650    let endpoint = if endpoint.contains("://") {
2651        endpoint.to_string()
2652    } else {
2653        format!("http://{}", endpoint)
2654    };
2655
2656    let parsed = Url::parse(&endpoint).map_err(|e| {
2657        SdkError::ValidationError(format!("invalid http_endpoint '{}': {}", endpoint, e))
2658    })?;
2659
2660    let host = parsed.host_str().ok_or_else(|| {
2661        SdkError::ValidationError(format!("http_endpoint '{}' missing host", endpoint))
2662    })?;
2663
2664    let scheme = parsed.scheme();
2665    let port = parsed.port_or_known_default().ok_or_else(|| {
2666        SdkError::ValidationError(format!(
2667            "http_endpoint '{}' missing known default port",
2668            endpoint
2669        ))
2670    })?;
2671
2672    let default_port = if scheme.eq_ignore_ascii_case("https") {
2673        443
2674    } else {
2675        80
2676    };
2677
2678    let normalized = if port == default_port {
2679        format!("{}://{}", scheme, host)
2680    } else {
2681        format!("{}://{}:{}", scheme, host, port)
2682    };
2683
2684    Ok(normalized)
2685}
2686
2687fn normalize_grpc_endpoint(endpoint: &str) -> Result<(String, bool)> {
2688    if endpoint.contains("://") {
2689        let parsed = Url::parse(endpoint).map_err(|e| {
2690            SdkError::ValidationError(format!("invalid grpc_endpoint '{}': {}", endpoint, e))
2691        })?;
2692        let host = parsed.host_str().ok_or_else(|| {
2693            SdkError::ValidationError(format!("grpc_endpoint '{}' missing host", endpoint))
2694        })?;
2695        let port = parsed.port_or_known_default().ok_or_else(|| {
2696            SdkError::ValidationError(format!(
2697                "grpc_endpoint '{}' missing known default port",
2698                endpoint
2699            ))
2700        })?;
2701        return Ok((
2702            format!("{}:{}", host, port),
2703            parsed.scheme().eq_ignore_ascii_case("https")
2704                || parsed.scheme().eq_ignore_ascii_case("grpcs"),
2705        ));
2706    }
2707
2708    let endpoint = endpoint.trim();
2709    if endpoint.is_empty() {
2710        return Err(SdkError::ValidationError(
2711            "grpc_endpoint cannot be empty".to_string(),
2712        ));
2713    }
2714
2715    if let Some((host, port_text)) = endpoint.rsplit_once(':') {
2716        if !host.trim().is_empty() {
2717            if let Ok(port) = port_text.parse::<u16>() {
2718                return Ok((format!("{}:{}", host, port), port == 443));
2719            }
2720        }
2721        return Ok((endpoint.to_string(), false));
2722    }
2723
2724    Ok((format!("{}:50051", endpoint), false))
2725}
2726
2727fn map_grpc_connect_error(error: tonic::transport::Error, endpoint: &str) -> SdkError {
2728    let lower = error.to_string().to_lowercase();
2729    let kind = if lower.contains("deadline") || lower.contains("timed out") {
2730        TransportFailureKind::DeadlineExceeded
2731    } else if lower.contains("connection reset") {
2732        TransportFailureKind::ConnectionReset
2733    } else if lower.contains("dns")
2734        || lower.contains("refused")
2735        || lower.contains("unavailable")
2736        || lower.contains("not connected")
2737    {
2738        TransportFailureKind::Unavailable
2739    } else {
2740        TransportFailureKind::Io
2741    };
2742
2743    SdkError::TransportError {
2744        kind,
2745        message: format!("failed to connect to gRPC endpoint {}: {}", endpoint, error),
2746    }
2747}
2748
2749fn map_grpc_status(status: Status) -> SdkError {
2750    let message = status.message().to_string();
2751    match status.code() {
2752        Code::Unauthenticated | Code::PermissionDenied => SdkError::AuthError(message),
2753        Code::InvalidArgument
2754        | Code::NotFound
2755        | Code::AlreadyExists
2756        | Code::FailedPrecondition
2757        | Code::OutOfRange => SdkError::ValidationError(message),
2758        Code::Unavailable => SdkError::TransportError {
2759            kind: TransportFailureKind::Unavailable,
2760            message,
2761        },
2762        Code::DeadlineExceeded => SdkError::TransportError {
2763            kind: TransportFailureKind::DeadlineExceeded,
2764            message,
2765        },
2766        Code::Unimplemented => SdkError::TransportError {
2767            kind: TransportFailureKind::Unimplemented,
2768            message,
2769        },
2770        Code::Cancelled => SdkError::TransportError {
2771            kind: TransportFailureKind::Io,
2772            message,
2773        },
2774        Code::Unknown | Code::Internal => {
2775            let lower = message.to_lowercase();
2776            if lower.contains("connection reset") {
2777                SdkError::TransportError {
2778                    kind: TransportFailureKind::ConnectionReset,
2779                    message,
2780                }
2781            } else if lower.contains("transport")
2782                || lower.contains("broken pipe")
2783                || lower.contains("io error")
2784            {
2785                SdkError::TransportError {
2786                    kind: TransportFailureKind::Io,
2787                    message,
2788                }
2789            } else {
2790                SdkError::ServerError(message)
2791            }
2792        }
2793        _ => SdkError::ServerError(message),
2794    }
2795}
2796
2797fn map_transport_error(error: reqwest::Error, message: String) -> SdkError {
2798    let lower = error.to_string().to_lowercase();
2799    let kind = if error.is_timeout() {
2800        TransportFailureKind::DeadlineExceeded
2801    } else if error.is_connect() {
2802        TransportFailureKind::Unavailable
2803    } else if lower.contains("connection reset") {
2804        TransportFailureKind::ConnectionReset
2805    } else if error.is_request() || error.is_body() {
2806        TransportFailureKind::Io
2807    } else {
2808        TransportFailureKind::Other
2809    };
2810
2811    SdkError::TransportError {
2812        kind,
2813        message: format!("{}: {}", message, error),
2814    }
2815}
2816
2817fn map_http_error(status: u16, body: String) -> SdkError {
2818    match status {
2819        401 | 403 => SdkError::AuthError(body),
2820        400 | 404 | 409 | 422 => SdkError::ValidationError(body),
2821        501 => SdkError::UnsupportedFeatureError(body),
2822        _ => SdkError::ServerError(body),
2823    }
2824}
2825
2826fn http_method_label(method: HttpMethod) -> &'static str {
2827    match method {
2828        HttpMethod::Get => "GET",
2829        HttpMethod::Post => "POST",
2830        HttpMethod::Delete => "DELETE",
2831    }
2832}