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    advanced: AdvancedClient,
915    transport: Arc<TransportEngine>,
916}
917
918impl Client {
919    pub fn new(config: ClientConfig) -> Result<Self> {
920        let transport = Arc::new(TransportEngine::new(config)?);
921        Ok(Self {
922            auth: AuthClient {
923                transport: transport.clone(),
924            },
925            core: CoreClient {
926                transport: transport.clone(),
927            },
928            control: ControlClient {
929                transport: transport.clone(),
930            },
931            advanced: AdvancedClient {
932                control: ControlClient {
933                    transport: transport.clone(),
934                },
935            },
936            transport,
937        })
938    }
939
940    /// Raw low-level control-plane surface. Every operation takes a free-form
941    /// serializable payload and returns raw JSON — the same ops that used to
942    /// be flattened onto `Client` directly (those flat delegates are now
943    /// deprecated in favour of this namespace). The ~24 typed helpers
944    /// (`remember`, `recall`, `handoff`, ...) stay on `Client`.
945    pub fn advanced(&self) -> &AdvancedClient {
946        &self.advanced
947    }
948
949    pub fn set_api_key(&self, api_key: Option<String>) {
950        self.transport.set_api_key(api_key);
951    }
952
953    pub fn set_token(&self, token: Option<String>) {
954        self.set_api_key(token);
955    }
956
957    pub fn set_run_id(&self, run_id: Option<String>) {
958        self.transport.set_run_id(run_id);
959    }
960
961    pub fn set_transport(&self, transport: TransportMode) {
962        self.transport.set_transport(transport);
963    }
964
965    /// Run-scope sugar: set the client's ambient run id (the same mechanism
966    /// that auto-injects `run_id` into every operation with a run_id field)
967    /// and restore the previous run id when the returned [`RunScope`] guard
968    /// drops. For explicit reflection at the end of the scope, call
969    /// [`RunScope::end_with_reflect`] instead of letting the guard drop (no
970    /// async work happens in `Drop`).
971    ///
972    /// ```ignore
973    /// {
974    ///     let _scope = client.run_scope("ticket-4711");
975    ///     client.remember(RememberBuilder::new("...").build()).await?;
976    /// } // previous run id restored here
977    /// ```
978    pub fn run_scope(&self, run_id: impl Into<String>) -> RunScope {
979        let run_id = run_id.into();
980        let previous = self.transport.run_id();
981        self.transport.set_run_id(Some(run_id.clone()));
982        RunScope {
983            client: self.clone(),
984            run_id,
985            previous,
986            restored: false,
987        }
988    }
989}
990
991/// Guard returned by [`Client::run_scope`]. Restores the client's previous
992/// ambient run id on `Drop`, or after [`RunScope::end_with_reflect`].
993pub struct RunScope {
994    client: Client,
995    run_id: String,
996    previous: Option<String>,
997    restored: bool,
998}
999
1000impl RunScope {
1001    /// The run id this scope pinned as the client's ambient run id.
1002    pub fn run_id(&self) -> &str {
1003        &self.run_id
1004    }
1005
1006    /// Explicitly end the scope with a reflection pass over the scoped run,
1007    /// then restore the previous ambient run id. Use this instead of relying
1008    /// on `Drop` when you want lessons distilled at the end of the run —
1009    /// `Drop` cannot await.
1010    pub async fn end_with_reflect(mut self) -> Result<Value> {
1011        let result = self
1012            .client
1013            .reflect(ReflectOptions {
1014                run_id: Some(self.run_id.clone()),
1015                ..ReflectOptions::default()
1016            })
1017            .await;
1018        self.restore();
1019        result
1020    }
1021
1022    fn restore(&mut self) {
1023        if !self.restored {
1024            self.restored = true;
1025            self.client.transport.set_run_id(self.previous.take());
1026        }
1027    }
1028}
1029
1030impl Drop for RunScope {
1031    fn drop(&mut self) {
1032        self.restore();
1033    }
1034}
1035
1036#[derive(Clone, Debug)]
1037pub struct RememberOptions {
1038    pub run_id: Option<String>,
1039    pub agent_id: Option<String>,
1040    pub item_id: Option<String>,
1041    pub content: String,
1042    pub content_type: String,
1043    pub metadata: Option<Value>,
1044    pub hints: Option<Value>,
1045    pub payload: Option<Value>,
1046    pub intent: Option<String>,
1047    pub lesson_type: Option<String>,
1048    pub lesson_scope: Option<String>,
1049    pub lesson_importance: Option<String>,
1050    pub lesson_conditions: Vec<String>,
1051    pub user_id: Option<String>,
1052    pub upsert_key: Option<String>,
1053    pub importance: Option<String>,
1054    pub source: Option<String>,
1055    pub lane: Option<String>,
1056    pub parallel: bool,
1057    pub idempotency_key: Option<String>,
1058    pub wait: bool,
1059    pub timeout_ms: Option<u64>,
1060    pub poll_interval_ms: u64,
1061    /// When the event actually occurred (unix seconds). Distinct from ingestion
1062    /// time. Used for temporal queries about when events happened vs when the
1063    /// system learned about them.
1064    pub occurrence_time: Option<i64>,
1065    /// Semantic environment tags in TYPE:NAME or TYPE:NAME:VERSION format,
1066    /// e.g. `["lang:python:3.12", "framework:langchain:0.3"]`.
1067    pub env_tags: Vec<String>,
1068    /// Ergonomic alias for the write-time lesson scope. Accepts "run",
1069    /// "session", "global"; "org" is promotion-gated (reached by server-side
1070    /// promotion of validated global lessons) and rejected with an
1071    /// explanatory error. Conflicts with an explicit differing `lesson_scope`.
1072    pub share: Option<String>,
1073}
1074
1075impl RememberOptions {
1076    pub fn new(content: impl Into<String>) -> Self {
1077        Self {
1078            run_id: None,
1079            agent_id: Some("sdk-client".to_string()),
1080            item_id: None,
1081            content: content.into(),
1082            content_type: "text/plain".to_string(),
1083            metadata: None,
1084            hints: None,
1085            payload: None,
1086            intent: None,
1087            lesson_type: None,
1088            lesson_scope: None,
1089            lesson_importance: None,
1090            lesson_conditions: Vec::new(),
1091            user_id: None,
1092            upsert_key: None,
1093            importance: None,
1094            source: Some("agent".to_string()),
1095            lane: None,
1096            parallel: false,
1097            idempotency_key: None,
1098            wait: true,
1099            timeout_ms: None,
1100            poll_interval_ms: 300,
1101            occurrence_time: None,
1102            env_tags: Vec::new(),
1103            share: None,
1104        }
1105    }
1106}
1107
1108/// Typed options for the `learned()` convenience helper.
1109#[derive(Clone, Debug, Default)]
1110pub struct LearnedOptions {
1111    pub run_id: Option<String>,
1112    pub content: String,
1113    pub importance: String,
1114    pub verified_in_production: bool,
1115    pub env_tags: Vec<String>,
1116    pub agent_id: Option<String>,
1117    pub user_id: Option<String>,
1118}
1119
1120impl LearnedOptions {
1121    pub fn new(content: impl Into<String>) -> Self {
1122        Self {
1123            run_id: None,
1124            content: content.into(),
1125            importance: "medium".to_string(),
1126            verified_in_production: false,
1127            env_tags: Vec::new(),
1128            agent_id: None,
1129            user_id: None,
1130        }
1131    }
1132}
1133
1134/// Phase 2.3: grouped lesson metadata. Replaces the flat `lesson_*`
1135/// quartet on `RememberOptions` so customers don't have to remember which
1136/// of twenty-odd parameters belong together. Used via
1137/// `RememberBuilder::lesson(LessonMeta { ... })`.
1138#[derive(Clone, Debug, Default)]
1139pub struct LessonMeta {
1140    pub lesson_type: Option<String>,
1141    pub lesson_scope: Option<String>,
1142    pub lesson_importance: Option<String>,
1143    pub lesson_conditions: Vec<String>,
1144}
1145
1146/// Phase 2.3: grouped session/agent/run scope. Caller passes one builder
1147/// method instead of three flat fields.
1148#[derive(Clone, Debug, Default)]
1149pub struct SessionScope {
1150    pub run_id: Option<String>,
1151    pub agent_id: Option<String>,
1152    pub user_id: Option<String>,
1153}
1154
1155/// Phase 2.3: fluent builder for `RememberOptions` — replaces the 23-param
1156/// flat construction with a grouped-chain API.
1157///
1158/// ```ignore
1159/// let opts = RememberBuilder::new("cache rebuild needs token rotation")
1160///     .lesson(LessonMeta {
1161///         lesson_type: Some("success".into()),
1162///         lesson_scope: Some("session".into()),
1163///         ..Default::default()
1164///     })
1165///     .session(SessionScope { run_id: Some(run_id), ..Default::default() })
1166///     .upsert("cache-rebuild-signer")
1167///     .build();
1168/// client.remember(opts).await?;
1169/// ```
1170pub struct RememberBuilder {
1171    options: RememberOptions,
1172}
1173
1174impl RememberBuilder {
1175    pub fn new(content: impl Into<String>) -> Self {
1176        Self {
1177            options: RememberOptions::new(content),
1178        }
1179    }
1180
1181    pub fn lesson(mut self, meta: LessonMeta) -> Self {
1182        self.options.lesson_type = meta.lesson_type;
1183        self.options.lesson_scope = meta.lesson_scope;
1184        self.options.lesson_importance = meta.lesson_importance;
1185        self.options.lesson_conditions = meta.lesson_conditions;
1186        self
1187    }
1188
1189    pub fn session(mut self, scope: SessionScope) -> Self {
1190        self.options.run_id = scope.run_id;
1191        self.options.agent_id = scope.agent_id.or(self.options.agent_id);
1192        self.options.user_id = scope.user_id;
1193        self
1194    }
1195
1196    pub fn metadata(mut self, metadata: Value) -> Self {
1197        self.options.metadata = Some(metadata);
1198        self
1199    }
1200
1201    pub fn hints(mut self, hints: Value) -> Self {
1202        self.options.hints = Some(hints);
1203        self
1204    }
1205
1206    pub fn payload(mut self, payload: Value) -> Self {
1207        self.options.payload = Some(payload);
1208        self
1209    }
1210
1211    pub fn upsert(mut self, key: impl Into<String>) -> Self {
1212        self.options.upsert_key = Some(key.into());
1213        self
1214    }
1215
1216    pub fn intent(mut self, intent: impl Into<String>) -> Self {
1217        self.options.intent = Some(intent.into());
1218        self
1219    }
1220
1221    pub fn lane(mut self, lane: impl Into<String>) -> Self {
1222        self.options.lane = Some(lane.into());
1223        self
1224    }
1225
1226    pub fn source(mut self, source: impl Into<String>) -> Self {
1227        self.options.source = Some(source.into());
1228        self
1229    }
1230
1231    pub fn importance(mut self, importance: impl Into<String>) -> Self {
1232        self.options.importance = Some(importance.into());
1233        self
1234    }
1235
1236    pub fn occurrence_time(mut self, ts_seconds: i64) -> Self {
1237        self.options.occurrence_time = Some(ts_seconds);
1238        self
1239    }
1240
1241    /// Write-time lesson sharing scope: "run", "session", or "global".
1242    /// "org" is promotion-gated and rejected when the options are submitted.
1243    pub fn share(mut self, scope: impl Into<String>) -> Self {
1244        self.options.share = Some(scope.into());
1245        self
1246    }
1247
1248    pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
1249        self.options.idempotency_key = Some(key.into());
1250        self
1251    }
1252
1253    pub fn wait(mut self, wait: bool) -> Self {
1254        self.options.wait = wait;
1255        self
1256    }
1257
1258    pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
1259        self.options.timeout_ms = Some(timeout_ms);
1260        self
1261    }
1262
1263    /// Finalise and hand back the underlying `RememberOptions`. Call
1264    /// `client.remember(builder.build()).await?` to submit.
1265    pub fn build(self) -> RememberOptions {
1266        self.options
1267    }
1268}
1269
1270#[derive(Clone, Debug)]
1271pub struct RecallOptions {
1272    pub run_id: Option<String>,
1273    pub query: String,
1274    pub schema: Option<String>,
1275    pub mode: String,
1276    pub direct_lane: String,
1277    pub include_linked_runs: bool,
1278    pub limit: u64,
1279    pub embedding: Vec<f32>,
1280    pub entry_types: Vec<String>,
1281    pub include_working_memory: bool,
1282    pub user_id: Option<String>,
1283    pub agent_id: Option<String>,
1284    pub lane: Option<String>,
1285    /// Temporal range filter lower bound (unix seconds, inclusive).
1286    pub min_timestamp: Option<i64>,
1287    /// Temporal range filter upper bound (unix seconds, inclusive).
1288    pub max_timestamp: Option<i64>,
1289    /// Search budget tier: "low", "mid", "high".
1290    pub budget: Option<String>,
1291    /// Ranking strategy: "relevance" (default), "freshness", "balanced".
1292    pub rank_by: Option<String>,
1293    /// When true, evidence items include an `explain_info` block detailing score components.
1294    pub explain: Option<bool>,
1295    /// When true, only evidence from the current run is returned. Cross-run
1296    /// session-scoped lessons (lesson_overlay) are skipped. Defaults to
1297    /// `None` / `false` — cross-run lesson retrieval is the intended
1298    /// behavior for durable learning; opt into strict scoping here.
1299    pub prefer_current_run: Option<bool>,
1300    /// When true, skip LLM answer synthesis entirely and return evidence only
1301    /// (`final_answer` empty, summary "evidence_only"). Saves the per-query
1302    /// LLM call for machine consumers of the evidence list.
1303    pub evidence_only: Option<bool>,
1304}
1305
1306impl RecallOptions {
1307    pub fn new(query: impl Into<String>) -> Self {
1308        Self {
1309            run_id: None,
1310            query: query.into(),
1311            schema: None,
1312            // Proto enum value names are case-sensitive on the wire. HTTP is
1313            // tolerant; gRPC is not. The Rust SDK's gRPC path has additional
1314            // serde strictness issues (missing-field requirements on prost
1315            // types) that cause it to auto-fall-back to HTTP today — see
1316            // docs/sdk-rust-grpc-known-issues. Using uppercase defaults is the
1317            // minimum fix that keeps gRPC viable when the rest is sorted.
1318            mode: "AGENT_ROUTED".to_string(),
1319            direct_lane: "SEMANTIC_SEARCH".to_string(),
1320            include_linked_runs: false,
1321            limit: 5,
1322            embedding: Vec::new(),
1323            entry_types: Vec::new(),
1324            include_working_memory: true,
1325            user_id: None,
1326            agent_id: None,
1327            lane: None,
1328            min_timestamp: None,
1329            max_timestamp: None,
1330            budget: None,
1331            rank_by: None,
1332            explain: None,
1333            prefer_current_run: None,
1334            evidence_only: None,
1335        }
1336    }
1337}
1338
1339#[derive(Clone, Debug)]
1340pub struct GetContextOptions {
1341    pub run_id: Option<String>,
1342    pub query: Option<String>,
1343    pub user_id: Option<String>,
1344    pub entry_types: Vec<String>,
1345    pub include_working_memory: bool,
1346    pub format: Option<String>,
1347    pub limit: Option<u64>,
1348    pub max_token_budget: Option<u32>,
1349    pub agent_id: Option<String>,
1350    pub mode: Option<String>,
1351    pub sections: Vec<String>,
1352    pub lane: Option<String>,
1353}
1354
1355impl Default for GetContextOptions {
1356    fn default() -> Self {
1357        Self {
1358            run_id: None,
1359            query: None,
1360            user_id: None,
1361            entry_types: Vec::new(),
1362            include_working_memory: true,
1363            format: None,
1364            limit: None,
1365            max_token_budget: None,
1366            agent_id: None,
1367            mode: None,
1368            sections: Vec::new(),
1369            lane: None,
1370        }
1371    }
1372}
1373
1374#[derive(Clone, Debug)]
1375pub struct ArchiveOptions {
1376    pub run_id: Option<String>,
1377    pub content: String,
1378    pub artifact_kind: String,
1379    pub metadata: Option<Value>,
1380    pub user_id: Option<String>,
1381    pub agent_id: Option<String>,
1382    pub origin_agent_id: Option<String>,
1383    pub source_attempt_id: Option<String>,
1384    pub source_tool: Option<String>,
1385    pub labels: Vec<String>,
1386    pub family: Option<String>,
1387    pub importance: Option<String>,
1388}
1389
1390impl ArchiveOptions {
1391    pub fn new(content: impl Into<String>, artifact_kind: impl Into<String>) -> Self {
1392        Self {
1393            run_id: None,
1394            content: content.into(),
1395            artifact_kind: artifact_kind.into(),
1396            metadata: None,
1397            user_id: None,
1398            agent_id: None,
1399            origin_agent_id: None,
1400            source_attempt_id: None,
1401            source_tool: None,
1402            labels: Vec::new(),
1403            family: None,
1404            importance: None,
1405        }
1406    }
1407}
1408
1409/// Phase 2.3: grouped artefact provenance. Replaces the five-parameter
1410/// (origin_agent_id / source_attempt_id / source_tool / labels / family)
1411/// cluster on `ArchiveOptions`.
1412#[derive(Clone, Debug, Default)]
1413pub struct ArtifactProvenance {
1414    pub origin_agent_id: Option<String>,
1415    pub source_attempt_id: Option<String>,
1416    pub source_tool: Option<String>,
1417    pub labels: Vec<String>,
1418    pub family: Option<String>,
1419}
1420
1421/// Phase 2.3: fluent builder for `ArchiveOptions` — replaces the 16-param
1422/// flat construction with a grouped-chain API.
1423///
1424/// ```ignore
1425/// let archive = ArchiveBuilder::new("report.md contents", "report")
1426///     .session(SessionScope { run_id: Some(run_id.clone()), ..Default::default() })
1427///     .provenance(ArtifactProvenance {
1428///         origin_agent_id: Some("writer".into()),
1429///         labels: vec!["monthly".into()],
1430///         ..Default::default()
1431///     })
1432///     .importance("high")
1433///     .build();
1434/// client.archive(archive).await?;
1435/// ```
1436pub struct ArchiveBuilder {
1437    options: ArchiveOptions,
1438}
1439
1440impl ArchiveBuilder {
1441    pub fn new(content: impl Into<String>, artifact_kind: impl Into<String>) -> Self {
1442        Self {
1443            options: ArchiveOptions::new(content, artifact_kind),
1444        }
1445    }
1446
1447    pub fn session(mut self, scope: SessionScope) -> Self {
1448        self.options.run_id = scope.run_id;
1449        self.options.agent_id = scope.agent_id;
1450        self.options.user_id = scope.user_id;
1451        self
1452    }
1453
1454    pub fn provenance(mut self, prov: ArtifactProvenance) -> Self {
1455        self.options.origin_agent_id = prov.origin_agent_id;
1456        self.options.source_attempt_id = prov.source_attempt_id;
1457        self.options.source_tool = prov.source_tool;
1458        self.options.labels = prov.labels;
1459        self.options.family = prov.family;
1460        self
1461    }
1462
1463    pub fn metadata(mut self, metadata: Value) -> Self {
1464        self.options.metadata = Some(metadata);
1465        self
1466    }
1467
1468    pub fn importance(mut self, importance: impl Into<String>) -> Self {
1469        self.options.importance = Some(importance.into());
1470        self
1471    }
1472
1473    pub fn label(mut self, label: impl Into<String>) -> Self {
1474        self.options.labels.push(label.into());
1475        self
1476    }
1477
1478    pub fn build(self) -> ArchiveOptions {
1479        self.options
1480    }
1481}
1482
1483#[derive(Clone, Debug)]
1484pub struct DereferenceOptions {
1485    pub run_id: Option<String>,
1486    pub reference_id: String,
1487    pub user_id: Option<String>,
1488    pub agent_id: Option<String>,
1489}
1490
1491impl DereferenceOptions {
1492    pub fn new(reference_id: impl Into<String>) -> Self {
1493        Self {
1494            run_id: None,
1495            reference_id: reference_id.into(),
1496            user_id: None,
1497            agent_id: None,
1498        }
1499    }
1500}
1501
1502#[derive(Clone, Debug)]
1503pub struct MemoryHealthOptions {
1504    pub run_id: Option<String>,
1505    pub user_id: Option<String>,
1506    pub stale_threshold_days: u32,
1507    pub limit: u32,
1508}
1509
1510impl Default for MemoryHealthOptions {
1511    fn default() -> Self {
1512        Self {
1513            run_id: None,
1514            user_id: None,
1515            stale_threshold_days: 30,
1516            limit: 500,
1517        }
1518    }
1519}
1520
1521#[derive(Clone, Debug)]
1522pub struct DiagnoseOptions {
1523    pub run_id: Option<String>,
1524    pub error_text: String,
1525    pub error_type: Option<String>,
1526    pub limit: u64,
1527    pub user_id: Option<String>,
1528}
1529
1530impl DiagnoseOptions {
1531    pub fn new(error_text: impl Into<String>) -> Self {
1532        Self {
1533            run_id: None,
1534            error_text: error_text.into(),
1535            error_type: None,
1536            limit: 10,
1537            user_id: None,
1538        }
1539    }
1540}
1541
1542#[derive(Clone, Debug, Default)]
1543pub struct ReflectOptions {
1544    pub run_id: Option<String>,
1545    pub include_linked_runs: bool,
1546    pub user_id: Option<String>,
1547    pub step_id: Option<String>,
1548    pub checkpoint_id: Option<String>,
1549    pub last_n_items: Option<u64>,
1550    pub include_step_outcomes: Option<bool>,
1551}
1552
1553#[derive(Clone, Debug, Default)]
1554pub struct ForgetOptions {
1555    pub run_id: Option<String>,
1556    pub lesson_id: Option<String>,
1557}
1558
1559impl ForgetOptions {
1560    pub fn for_run(run_id: impl Into<String>) -> Self {
1561        Self {
1562            run_id: Some(run_id.into()),
1563            lesson_id: None,
1564        }
1565    }
1566
1567    pub fn for_lesson(lesson_id: impl Into<String>) -> Self {
1568        Self {
1569            run_id: None,
1570            lesson_id: Some(lesson_id.into()),
1571        }
1572    }
1573}
1574
1575#[derive(Clone, Debug)]
1576pub struct CheckpointOptions {
1577    pub run_id: Option<String>,
1578    pub label: Option<String>,
1579    pub context_snapshot: String,
1580    pub metadata: Option<Value>,
1581    pub user_id: Option<String>,
1582    pub agent_id: Option<String>,
1583}
1584
1585impl CheckpointOptions {
1586    pub fn new(context_snapshot: impl Into<String>) -> Self {
1587        Self {
1588            run_id: None,
1589            label: None,
1590            context_snapshot: context_snapshot.into(),
1591            metadata: None,
1592            user_id: None,
1593            agent_id: None,
1594        }
1595    }
1596}
1597
1598#[derive(Clone, Debug)]
1599pub struct RegisterAgentOptions {
1600    pub run_id: Option<String>,
1601    pub agent_id: String,
1602    pub role: String,
1603    pub capabilities: Vec<String>,
1604    pub status: String,
1605    pub read_scopes: Vec<String>,
1606    pub write_scopes: Vec<String>,
1607    pub shared_memory_lanes: Vec<String>,
1608}
1609
1610impl RegisterAgentOptions {
1611    pub fn new(agent_id: impl Into<String>) -> Self {
1612        Self {
1613            run_id: None,
1614            agent_id: agent_id.into(),
1615            role: String::new(),
1616            capabilities: Vec::new(),
1617            status: "active".to_string(),
1618            read_scopes: Vec::new(),
1619            write_scopes: Vec::new(),
1620            shared_memory_lanes: Vec::new(),
1621        }
1622    }
1623}
1624
1625#[derive(Clone, Debug, Default)]
1626pub struct ListAgentsOptions {
1627    pub run_id: Option<String>,
1628}
1629
1630#[derive(Clone, Debug)]
1631pub struct RecordOutcomeOptions {
1632    pub run_id: Option<String>,
1633    pub reference_id: String,
1634    pub outcome: String,
1635    pub signal: f32,
1636    pub rationale: String,
1637    pub agent_id: Option<String>,
1638    pub user_id: Option<String>,
1639    /// When true, this lesson was applied and verified in a live production
1640    /// environment. Latches a ranking boost on subsequent retrieval.
1641    pub verified_in_production: bool,
1642    /// Additional memory entry IDs that contributed to this outcome. Each is
1643    /// reinforced (success/failure counters + reinforcement) alongside the
1644    /// primary `reference_id`, which is never double-counted.
1645    pub entry_ids: Vec<String>,
1646    /// Client-supplied dedup token. When set, the server applies this outcome
1647    /// at most once for the token — a retry with the same token returns
1648    /// success without re-applying the signal.
1649    pub idempotency_key: Option<String>,
1650}
1651
1652impl RecordOutcomeOptions {
1653    pub fn new(reference_id: impl Into<String>, outcome: impl Into<String>) -> Self {
1654        Self {
1655            run_id: None,
1656            reference_id: reference_id.into(),
1657            outcome: outcome.into(),
1658            signal: 0.0,
1659            rationale: String::new(),
1660            agent_id: None,
1661            user_id: None,
1662            verified_in_production: false,
1663            entry_ids: Vec::new(),
1664            idempotency_key: None,
1665        }
1666    }
1667}
1668
1669/// Phase 2.4: typed options for `optimize_prompt` — produces a challenger
1670/// prompt version that Phase 1.1's evaluator will surface as
1671/// `ready_for_promotion` once it accumulates enough outcomes. The high-level
1672/// wrapper on `Client` hides the raw `OptimizePromptRequest` shape and
1673/// defaults `auto_activate` to false (customers activate deliberately).
1674#[derive(Clone, Debug, Default)]
1675pub struct OptimizePromptOptions {
1676    pub agent_id: String,
1677    /// When true, the server activates the newly generated version
1678    /// immediately if its confidence exceeds the server-side threshold.
1679    /// Defaults to false so the champion/challenger evaluator drives
1680    /// activation instead.
1681    pub auto_activate: bool,
1682    pub run_id: Option<String>,
1683    /// Project scope. When present, optimisation ingests cross-agent lessons
1684    /// from the entire project.
1685    pub project_id: Option<String>,
1686}
1687
1688impl OptimizePromptOptions {
1689    pub fn new(agent_id: impl Into<String>) -> Self {
1690        Self {
1691            agent_id: agent_id.into(),
1692            auto_activate: false,
1693            run_id: None,
1694            project_id: None,
1695        }
1696    }
1697}
1698
1699/// Phase 2.4: typed options for `optimize_skill`.
1700#[derive(Clone, Debug, Default)]
1701pub struct OptimizeSkillOptions {
1702    pub skill_id: String,
1703    pub auto_activate: bool,
1704    pub project_id: Option<String>,
1705}
1706
1707impl OptimizeSkillOptions {
1708    pub fn new(skill_id: impl Into<String>) -> Self {
1709        Self {
1710            skill_id: skill_id.into(),
1711            auto_activate: false,
1712            project_id: None,
1713        }
1714    }
1715}
1716
1717/// Phase 1.5: options for `circuit_break`. The optional `reason` is recorded
1718/// on the snapshot lesson and the `CIRCUIT_BROKEN` event payload — pass a
1719/// short tag like "repeated_query" or "drift_detected" so later diagnosis is
1720/// easier.
1721#[derive(Clone, Debug, Default)]
1722pub struct CircuitBreakOptions {
1723    pub run_id: Option<String>,
1724    pub reason: Option<String>,
1725    pub agent_id: Option<String>,
1726}
1727
1728impl CircuitBreakOptions {
1729    pub fn new() -> Self {
1730        Self::default()
1731    }
1732    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
1733        self.reason = Some(reason.into());
1734        self
1735    }
1736}
1737
1738#[derive(Clone, Debug)]
1739pub struct RecordStepOutcomeOptions {
1740    pub run_id: Option<String>,
1741    pub step_id: String,
1742    pub step_name: Option<String>,
1743    pub outcome: String,
1744    pub signal: f32,
1745    pub rationale: String,
1746    pub directive_hint: Option<String>,
1747    pub agent_id: Option<String>,
1748    pub user_id: Option<String>,
1749    pub metadata: Option<Value>,
1750}
1751
1752impl RecordStepOutcomeOptions {
1753    pub fn new(step_id: impl Into<String>, outcome: impl Into<String>) -> Self {
1754        Self {
1755            run_id: None,
1756            step_id: step_id.into(),
1757            step_name: None,
1758            outcome: outcome.into(),
1759            signal: 0.0,
1760            rationale: String::new(),
1761            directive_hint: None,
1762            agent_id: None,
1763            user_id: None,
1764            metadata: None,
1765        }
1766    }
1767}
1768
1769#[derive(Clone, Debug)]
1770pub struct SurfaceStrategiesOptions {
1771    pub run_id: Option<String>,
1772    pub lesson_types: Vec<String>,
1773    pub max_strategies: u32,
1774    pub user_id: Option<String>,
1775}
1776
1777impl Default for SurfaceStrategiesOptions {
1778    fn default() -> Self {
1779        Self {
1780            run_id: None,
1781            lesson_types: Vec::new(),
1782            max_strategies: 5,
1783            user_id: None,
1784        }
1785    }
1786}
1787
1788#[derive(Clone, Debug)]
1789pub struct HandoffOptions {
1790    pub run_id: Option<String>,
1791    pub task_id: String,
1792    pub from_agent_id: String,
1793    pub to_agent_id: String,
1794    pub content: String,
1795    pub requested_action: String,
1796    pub metadata: Option<Value>,
1797    pub user_id: Option<String>,
1798    /// When true, `handoff()` polls (2s interval) for a feedback entry
1799    /// referencing the new handoff_id and returns the verdict as
1800    /// `Value::String` — or `Value::Null` when `timeout_s` elapses first.
1801    pub await_verdict: bool,
1802    /// Verdict polling timeout in seconds (default 30). Only used when
1803    /// `await_verdict` is true.
1804    pub timeout_s: f64,
1805}
1806
1807impl HandoffOptions {
1808    pub fn new(
1809        task_id: impl Into<String>,
1810        from_agent_id: impl Into<String>,
1811        to_agent_id: impl Into<String>,
1812        content: impl Into<String>,
1813    ) -> Self {
1814        Self {
1815            run_id: None,
1816            task_id: task_id.into(),
1817            from_agent_id: from_agent_id.into(),
1818            to_agent_id: to_agent_id.into(),
1819            content: content.into(),
1820            requested_action: "continue".to_string(),
1821            metadata: None,
1822            user_id: None,
1823            await_verdict: false,
1824            timeout_s: 30.0,
1825        }
1826    }
1827}
1828
1829/// Options for `receive_handoffs` — the receiver-side handoff inbox.
1830#[derive(Clone, Debug)]
1831pub struct ReceiveHandoffsOptions {
1832    /// Receiving agent: only handoffs addressed to this agent_id are returned.
1833    pub agent_id: String,
1834    pub run_id: Option<String>,
1835    pub limit: u64,
1836}
1837
1838impl ReceiveHandoffsOptions {
1839    pub fn new(agent_id: impl Into<String>) -> Self {
1840        Self {
1841            agent_id: agent_id.into(),
1842            run_id: None,
1843            limit: 20,
1844        }
1845    }
1846}
1847
1848/// A handoff addressed to the querying agent, as surfaced by
1849/// `receive_handoffs`. Thin typed wrapper over the raw query evidence —
1850/// the well-known metadata fields are lifted out, and the untouched
1851/// evidence item is kept in `evidence`.
1852#[derive(Clone, Debug)]
1853pub struct ReceivedHandoff {
1854    pub handoff_id: Option<String>,
1855    pub task_id: Option<String>,
1856    pub from_agent_id: Option<String>,
1857    pub to_agent_id: String,
1858    pub requested_action: Option<String>,
1859    pub content: Option<String>,
1860    pub created_at: Option<String>,
1861    /// Parsed handoff metadata object.
1862    pub metadata: Value,
1863    /// The raw query evidence item this wrapper was built from.
1864    pub evidence: Value,
1865}
1866
1867#[derive(Clone, Debug)]
1868pub struct FeedbackOptions {
1869    pub run_id: Option<String>,
1870    pub handoff_id: String,
1871    pub verdict: String,
1872    pub comments: String,
1873    pub from_agent_id: Option<String>,
1874    pub metadata: Option<Value>,
1875    pub user_id: Option<String>,
1876}
1877
1878impl FeedbackOptions {
1879    pub fn new(handoff_id: impl Into<String>, verdict: impl Into<String>) -> Self {
1880        Self {
1881            run_id: None,
1882            handoff_id: handoff_id.into(),
1883            verdict: verdict.into(),
1884            comments: String::new(),
1885            from_agent_id: None,
1886            metadata: None,
1887            user_id: None,
1888        }
1889    }
1890}
1891
1892impl Client {
1893    pub async fn remember(&self, options: RememberOptions) -> Result<Value> {
1894        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "remember")?;
1895        let content = require_non_empty_string(options.content, "content")?;
1896        // `share` is the ergonomic alias for the write-time lesson scope.
1897        // Accepts "run" | "session" | "global"; "org" is promotion-gated and
1898        // rejected with an explanatory error.
1899        let lesson_scope = resolve_share_scope(options.share, options.lesson_scope)?;
1900        let item_id = options
1901            .item_id
1902            .unwrap_or_else(|| generate_helper_id("remember"));
1903        let accepted = self
1904            .control
1905            .ingest(prune_nulls(json!({
1906                "run_id": run_id,
1907                "agent_id": options.agent_id.unwrap_or_else(|| "sdk-client".to_string()),
1908                "idempotency_key": options.idempotency_key.unwrap_or_else(|| item_id.clone()),
1909                "parallel": options.parallel,
1910                "items": [{
1911                    "item_id": item_id,
1912                    "content_type": options.content_type,
1913                    "text": content,
1914                    "payload_json": encode_optional_json(options.payload.as_ref())?,
1915                    "hints_json": encode_optional_json(options.hints.as_ref())?,
1916                    "metadata_json": encode_optional_json(options.metadata.as_ref())?,
1917                    "intent": options.intent,
1918                    "lesson_type": options.lesson_type,
1919                    "lesson_scope": lesson_scope,
1920                    "lesson_importance": options.lesson_importance,
1921                    "lesson_conditions_json": encode_string_vec(&options.lesson_conditions)?,
1922                    "user_id": options.user_id,
1923                    "upsert_key": options.upsert_key,
1924                    "importance": options.importance,
1925                    "source": options.source.unwrap_or_else(|| "agent".to_string()),
1926                    "lane": options.lane,
1927                    "occurrence_time": options.occurrence_time.unwrap_or(0),
1928                    "env_tags": if options.env_tags.is_empty() { Value::Null } else { json!(options.env_tags) },
1929                }],
1930            })))
1931            .await?;
1932
1933        if !options.wait {
1934            return Ok(accepted);
1935        }
1936
1937        let Some(job_id) = accepted.get("job_id").and_then(|value| value.as_str()) else {
1938            return Ok(accepted);
1939        };
1940
1941        self.wait_for_ingest_job(
1942            &run_id,
1943            job_id,
1944            options
1945                .timeout_ms
1946                .unwrap_or_else(|| self.transport.timeout.as_millis() as u64),
1947            options.poll_interval_ms,
1948        )
1949        .await
1950    }
1951
1952    pub async fn recall(&self, options: RecallOptions) -> Result<Value> {
1953        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "recall")?;
1954        self.control
1955            .query(prune_nulls(json!({
1956                "run_id": run_id,
1957                "query": require_non_empty_string(options.query, "query")?,
1958                "schema": options.schema,
1959                "mode": options.mode,
1960                "direct_lane": options.direct_lane,
1961                "include_linked_runs": options.include_linked_runs,
1962                "limit": options.limit,
1963                "embedding": options.embedding,
1964                "entry_types": if options.entry_types.is_empty() { Value::Null } else { json!(options.entry_types) },
1965                "include_working_memory": options.include_working_memory,
1966                "user_id": options.user_id,
1967                "agent_id": options.agent_id,
1968                "lane": options.lane,
1969                "min_timestamp": options.min_timestamp.unwrap_or(0),
1970                "max_timestamp": options.max_timestamp.unwrap_or(0),
1971                "budget": options.budget,
1972                "rank_by": options.rank_by,
1973                "explain": options.explain,
1974                "prefer_current_run": options.prefer_current_run,
1975                "evidence_only": options.evidence_only,
1976            })))
1977            .await
1978    }
1979
1980    pub async fn get_context(&self, options: GetContextOptions) -> Result<Value> {
1981        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "get_context")?;
1982        self.control
1983            .context(prune_nulls(json!({
1984                "run_id": run_id,
1985                "query": require_non_empty_string(options.query.unwrap_or_default(), "query")?,
1986                "user_id": options.user_id,
1987                "entry_types": if options.entry_types.is_empty() { Value::Null } else { json!(options.entry_types) },
1988                "include_working_memory": options.include_working_memory,
1989                "format": options.format.unwrap_or_else(|| "structured".to_string()),
1990                "limit": options.limit.unwrap_or(5),
1991                "max_token_budget": options.max_token_budget.unwrap_or(0),
1992                "agent_id": options.agent_id,
1993                "mode": options.mode.unwrap_or_else(|| "full".to_string()),
1994                "sections": if options.sections.is_empty() { Value::Null } else { json!(options.sections) },
1995                "lane": options.lane,
1996            })))
1997            .await
1998    }
1999
2000    pub async fn archive(&self, options: ArchiveOptions) -> Result<Value> {
2001        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "archive")?;
2002        let content = require_non_empty_string(options.content, "content")?;
2003        let artifact_kind = require_non_empty_string(options.artifact_kind, "artifact_kind")?;
2004        let agent_id = options.agent_id.clone();
2005        self.control
2006            .archive_block(prune_nulls(json!({
2007                "run_id": run_id,
2008                "content": content,
2009                "artifact_kind": artifact_kind,
2010                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2011                "user_id": options.user_id,
2012                "agent_id": agent_id.clone(),
2013                "origin_agent_id": options.origin_agent_id.or(agent_id),
2014                "source_attempt_id": options.source_attempt_id,
2015                "source_tool": options.source_tool,
2016                "labels": if options.labels.is_empty() { Value::Null } else { json!(options.labels) },
2017                "family": options.family,
2018                "importance": options.importance,
2019            })))
2020            .await
2021    }
2022
2023    pub async fn archive_block(&self, options: ArchiveOptions) -> Result<Value> {
2024        self.archive(options).await
2025    }
2026
2027    pub async fn dereference(&self, options: DereferenceOptions) -> Result<Value> {
2028        let run_id =
2029            resolve_helper_run_id(options.run_id, self.transport.run_id(), "dereference")?;
2030        self.control
2031            .dereference(prune_nulls(json!({
2032                "run_id": run_id,
2033                "reference_id": require_non_empty_string(options.reference_id, "reference_id")?,
2034                "user_id": options.user_id,
2035                "agent_id": options.agent_id,
2036            })))
2037            .await
2038    }
2039
2040    pub async fn memory_health(&self, options: MemoryHealthOptions) -> Result<Value> {
2041        let run_id =
2042            resolve_helper_run_id(options.run_id, self.transport.run_id(), "memory_health")?;
2043        self.control
2044            .memory_health(prune_nulls(json!({
2045                "run_id": run_id,
2046                "user_id": options.user_id,
2047                "stale_threshold_days": options.stale_threshold_days,
2048                "limit": options.limit,
2049            })))
2050            .await
2051    }
2052
2053    pub async fn diagnose(&self, options: DiagnoseOptions) -> Result<Value> {
2054        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "diagnose")?;
2055        self.control
2056            .diagnose(prune_nulls(json!({
2057                "run_id": run_id,
2058                "error_text": require_non_empty_string(options.error_text, "error_text")?,
2059                "error_type": options.error_type,
2060                "limit": options.limit,
2061                "user_id": options.user_id,
2062            })))
2063            .await
2064    }
2065
2066    pub async fn reflect(&self, options: ReflectOptions) -> Result<Value> {
2067        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "reflect")?;
2068        self.control
2069            .reflect(prune_nulls(json!({
2070                "run_id": run_id,
2071                "include_linked_runs": options.include_linked_runs,
2072                "user_id": options.user_id,
2073                "step_id": options.step_id,
2074                "checkpoint_id": options.checkpoint_id,
2075                "last_n_items": options.last_n_items,
2076                "include_step_outcomes": options.include_step_outcomes,
2077            })))
2078            .await
2079    }
2080
2081    pub async fn forget(&self, options: ForgetOptions) -> Result<Value> {
2082        let delete_lesson = options
2083            .lesson_id
2084            .as_ref()
2085            .map(|value| !value.trim().is_empty())
2086            .unwrap_or(false);
2087        let run_id = if options.run_id.is_some() {
2088            options.run_id
2089        } else if delete_lesson {
2090            None
2091        } else {
2092            self.transport.run_id()
2093        };
2094        let delete_run = run_id
2095            .as_ref()
2096            .map(|value| !value.trim().is_empty())
2097            .unwrap_or(false);
2098
2099        if (delete_lesson as u8) + (delete_run as u8) != 1 {
2100            return Err(SdkError::ValidationError(
2101                "forget requires either lesson_id or run_id, but not both".to_string(),
2102            ));
2103        }
2104
2105        if delete_lesson {
2106            return self
2107                .control
2108                .delete_lesson(json!({ "lesson_id": options.lesson_id.unwrap_or_default() }))
2109                .await;
2110        }
2111
2112        self.control
2113            .delete_run(json!({ "run_id": run_id.unwrap_or_default() }))
2114            .await
2115    }
2116
2117    pub async fn checkpoint(&self, options: CheckpointOptions) -> Result<Value> {
2118        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "checkpoint")?;
2119        self.control
2120            .checkpoint(prune_nulls(json!({
2121                "run_id": run_id,
2122                "label": options.label,
2123                "context_snapshot": require_non_empty_string(options.context_snapshot, "context_snapshot")?,
2124                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2125                "user_id": options.user_id,
2126                "agent_id": options.agent_id,
2127            })))
2128            .await
2129    }
2130
2131    pub async fn register_agent(&self, options: RegisterAgentOptions) -> Result<Value> {
2132        let run_id =
2133            resolve_helper_run_id(options.run_id, self.transport.run_id(), "register_agent")?;
2134        self.control
2135            .register_agent(prune_nulls(json!({
2136                "run_id": run_id,
2137                "agent_id": require_non_empty_string(options.agent_id, "agent_id")?,
2138                "role": options.role,
2139                "capabilities": if options.capabilities.is_empty() { Value::Null } else { json!(options.capabilities) },
2140                "status": options.status,
2141                "read_scopes": if options.read_scopes.is_empty() { Value::Null } else { json!(options.read_scopes) },
2142                "write_scopes": if options.write_scopes.is_empty() { Value::Null } else { json!(options.write_scopes) },
2143                "shared_memory_lanes": if options.shared_memory_lanes.is_empty() { Value::Null } else { json!(options.shared_memory_lanes) },
2144            })))
2145            .await
2146    }
2147
2148    pub async fn list_agents(&self, options: ListAgentsOptions) -> Result<Value> {
2149        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "list_agents")?;
2150        self.control.list_agents(json!({ "run_id": run_id })).await
2151    }
2152
2153    pub async fn record_outcome(&self, options: RecordOutcomeOptions) -> Result<Value> {
2154        let run_id =
2155            resolve_helper_run_id(options.run_id, self.transport.run_id(), "record_outcome")?;
2156        self.control
2157            .record_outcome(prune_nulls(json!({
2158                "run_id": run_id,
2159                "reference_id": require_non_empty_string(options.reference_id, "reference_id")?,
2160                "outcome": require_non_empty_string(options.outcome, "outcome")?,
2161                "signal": options.signal,
2162                "rationale": options.rationale,
2163                "agent_id": options.agent_id,
2164                "user_id": options.user_id,
2165                "verified_in_production": if options.verified_in_production { Some(true) } else { None },
2166                "entry_ids": if options.entry_ids.is_empty() { None } else { Some(options.entry_ids) },
2167                "idempotency_key": options.idempotency_key,
2168            })))
2169            .await
2170    }
2171
2172    pub async fn learned(&self, options: LearnedOptions) -> Result<Value> {
2173        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "learned")?;
2174        let metadata = if options.verified_in_production {
2175            let mut meta: serde_json::Map<String, Value> = serde_json::Map::new();
2176            meta.insert("verified_in_production".to_string(), Value::Bool(true));
2177            Some(Value::Object(meta))
2178        } else {
2179            None
2180        };
2181
2182        self.control
2183            .batch_insert(prune_nulls(json!({
2184                "run_id": run_id,
2185                "items": [{
2186                    "content": require_non_empty_string(options.content, "content")?,
2187                    "entry_type": "lesson",
2188                    "intent": "lesson",
2189                    "lesson_type": "success",
2190                    "lesson_scope": "session",
2191                    "lesson_importance": options.importance,
2192                    "metadata_json": encode_optional_json(metadata.as_ref())?,
2193                    "agent_id": options.agent_id,
2194                    "user_id": options.user_id,
2195                    "env_tags": if options.env_tags.is_empty() { Value::Null } else { json!(options.env_tags) },
2196                }],
2197            })))
2198            .await
2199    }
2200
2201    /// Phase 2.4: generate a challenger prompt version from accumulated
2202    /// lessons and outcome stats. The server never auto-activates unless
2203    /// `auto_activate=true` explicitly; by default the challenger surfaces
2204    /// via the champion/challenger evaluator (Phase 1.1) with
2205    /// `ready_for_promotion=true` once it outperforms the active version.
2206    pub async fn optimize_prompt(&self, options: OptimizePromptOptions) -> Result<Value> {
2207        let run_id = options
2208            .run_id
2209            .clone()
2210            .or_else(|| self.transport.run_id())
2211            .unwrap_or_default();
2212        self.control
2213            .optimize_prompt(prune_nulls(json!({
2214                "agent_id": require_non_empty_string(options.agent_id, "agent_id")?,
2215                "auto_activate": options.auto_activate,
2216                "run_id": if run_id.is_empty() { None } else { Some(run_id) },
2217                "project_id": options.project_id,
2218            })))
2219            .await
2220    }
2221
2222    /// Phase 2.4: skill-version analogue of `optimize_prompt`.
2223    pub async fn optimize_skill(&self, options: OptimizeSkillOptions) -> Result<Value> {
2224        self.control
2225            .optimize_skill(prune_nulls(json!({
2226                "skill_id": require_non_empty_string(options.skill_id, "skill_id")?,
2227                "auto_activate": options.auto_activate,
2228                "project_id": options.project_id,
2229            })))
2230            .await
2231    }
2232
2233    /// Phase 1.5: atomic anti-loop reset. Snapshots working memory to LTM as
2234    /// a `loop_detected` lesson, clears working memory, resets the drift
2235    /// monitor state, and emits a `CIRCUIT_BROKEN` event. Customers should
2236    /// invoke this after observing `signals.repeated == true` (or stagnant /
2237    /// drifting) on a recall response.
2238    pub async fn circuit_break(&self, options: CircuitBreakOptions) -> Result<Value> {
2239        let run_id =
2240            resolve_helper_run_id(options.run_id, self.transport.run_id(), "circuit_break")?;
2241        self.control
2242            .circuit_break(prune_nulls(json!({
2243                "run_id": run_id,
2244                "reason": options.reason,
2245                "agent_id": options.agent_id,
2246            })))
2247            .await
2248    }
2249
2250    pub async fn record_step_outcome(&self, options: RecordStepOutcomeOptions) -> Result<Value> {
2251        let run_id =
2252            resolve_helper_run_id(options.run_id, self.transport.run_id(), "record_step_outcome")?;
2253        self.control
2254            .record_step_outcome(prune_nulls(json!({
2255                "run_id": run_id,
2256                "step_id": require_non_empty_string(options.step_id, "step_id")?,
2257                "step_name": options.step_name,
2258                "outcome": require_non_empty_string(options.outcome, "outcome")?,
2259                "signal": options.signal,
2260                "rationale": options.rationale,
2261                "directive_hint": options.directive_hint,
2262                "agent_id": options.agent_id,
2263                "user_id": options.user_id,
2264                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2265            })))
2266            .await
2267    }
2268
2269    pub async fn surface_strategies(&self, options: SurfaceStrategiesOptions) -> Result<Value> {
2270        let run_id = resolve_helper_run_id(
2271            options.run_id,
2272            self.transport.run_id(),
2273            "surface_strategies",
2274        )?;
2275        self.control
2276            .surface_strategies(prune_nulls(json!({
2277                "run_id": run_id,
2278                "lesson_types": if options.lesson_types.is_empty() { Value::Null } else { json!(options.lesson_types) },
2279                "max_strategies": options.max_strategies,
2280                "user_id": options.user_id,
2281            })))
2282            .await
2283    }
2284
2285    /// Hand a task off to another agent.
2286    ///
2287    /// With `await_verdict = true`, polls (2s interval) for a feedback entry
2288    /// referencing the new handoff_id and returns the verdict
2289    /// ("approve" | "request_changes" | "block" | "acknowledge") as
2290    /// `Value::String` — or `Value::Null` when `timeout_s` (default 30)
2291    /// elapses without a verdict. Without it, returns the handoff response
2292    /// as before.
2293    pub async fn handoff(&self, options: HandoffOptions) -> Result<Value> {
2294        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "handoff")?;
2295        let response = self
2296            .control
2297            .create_handoff(prune_nulls(json!({
2298                "run_id": run_id,
2299                "task_id": require_non_empty_string(options.task_id, "task_id")?,
2300                "from_agent_id": require_non_empty_string(options.from_agent_id, "from_agent_id")?,
2301                "to_agent_id": require_non_empty_string(options.to_agent_id, "to_agent_id")?,
2302                "content": require_non_empty_string(options.content, "content")?,
2303                "requested_action": options.requested_action,
2304                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2305                "user_id": options.user_id,
2306            })))
2307            .await?;
2308
2309        if !options.await_verdict {
2310            return Ok(response);
2311        }
2312        let Some(handoff_id) = response
2313            .get("handoff_id")
2314            .and_then(Value::as_str)
2315            .filter(|value| !value.is_empty())
2316            .map(str::to_string)
2317        else {
2318            return Ok(Value::Null);
2319        };
2320        self.await_handoff_verdict(&run_id, &handoff_id, options.timeout_s)
2321            .await
2322    }
2323
2324    /// Poll (2s interval) for a feedback entry referencing `handoff_id`.
2325    /// Returns `Value::String(verdict)` or `Value::Null` on timeout.
2326    async fn await_handoff_verdict(
2327        &self,
2328        run_id: &str,
2329        handoff_id: &str,
2330        timeout_s: f64,
2331    ) -> Result<Value> {
2332        const POLL_INTERVAL: Duration = Duration::from_secs(2);
2333        let deadline = Instant::now() + Duration::from_secs_f64(timeout_s.max(0.0));
2334        loop {
2335            let result = self
2336                .control
2337                .query(prune_nulls(json!({
2338                    "run_id": run_id,
2339                    "query": format!("feedback for handoff {}", handoff_id),
2340                    "entry_types": ["feedback"],
2341                    "evidence_only": true,
2342                    "limit": 25,
2343                    "include_working_memory": false,
2344                })))
2345                .await?;
2346            let empty = Vec::new();
2347            let evidence_items = result
2348                .get("evidence")
2349                .and_then(Value::as_array)
2350                .unwrap_or(&empty);
2351            for evidence in evidence_items {
2352                let metadata = evidence_metadata_value(evidence);
2353                if metadata.get("handoff_id").and_then(Value::as_str) != Some(handoff_id) {
2354                    continue;
2355                }
2356                if let Some(verdict) = metadata
2357                    .get("verdict")
2358                    .and_then(Value::as_str)
2359                    .filter(|value| !value.is_empty())
2360                {
2361                    return Ok(Value::String(verdict.to_string()));
2362                }
2363            }
2364            if Instant::now() >= deadline {
2365                return Ok(Value::Null);
2366            }
2367            sleep(POLL_INTERVAL).await;
2368        }
2369    }
2370
2371    /// Receiver-side handoff inbox.
2372    ///
2373    /// Implemented client-side over `query` with `entry_types = ["handoff"]`
2374    /// (evidence only, no LLM synthesis) and filtered to evidence whose
2375    /// metadata addresses `to_agent_id == agent_id`. Each match is returned
2376    /// as a [`ReceivedHandoff`] wrapper around the raw evidence item.
2377    pub async fn receive_handoffs(
2378        &self,
2379        options: ReceiveHandoffsOptions,
2380    ) -> Result<Vec<ReceivedHandoff>> {
2381        let run_id = resolve_helper_run_id(
2382            options.run_id,
2383            self.transport.run_id(),
2384            "receive_handoffs",
2385        )?;
2386        let agent_id = require_non_empty_string(options.agent_id, "agent_id")?;
2387        let result = self
2388            .control
2389            .query(prune_nulls(json!({
2390                "run_id": run_id,
2391                "query": format!("handoffs for agent {}", agent_id),
2392                "entry_types": ["handoff"],
2393                "evidence_only": true,
2394                "limit": options.limit,
2395                "include_working_memory": false,
2396            })))
2397            .await?;
2398
2399        let empty = Vec::new();
2400        let evidence_items = result
2401            .get("evidence")
2402            .and_then(Value::as_array)
2403            .unwrap_or(&empty);
2404        let mut handoffs = Vec::new();
2405        for evidence in evidence_items {
2406            let metadata = evidence_metadata_value(evidence);
2407            let Some(to_agent) = metadata.get("to_agent_id").and_then(Value::as_str) else {
2408                continue;
2409            };
2410            if to_agent != agent_id {
2411                continue;
2412            }
2413            let meta_str = |key: &str| {
2414                metadata
2415                    .get(key)
2416                    .and_then(Value::as_str)
2417                    .map(str::to_string)
2418            };
2419            handoffs.push(ReceivedHandoff {
2420                handoff_id: evidence
2421                    .get("id")
2422                    .and_then(Value::as_str)
2423                    .filter(|value| !value.is_empty())
2424                    .or_else(|| evidence.get("reference_id").and_then(Value::as_str))
2425                    .map(str::to_string),
2426                task_id: meta_str("task_id"),
2427                from_agent_id: meta_str("from_agent_id"),
2428                to_agent_id: to_agent.to_string(),
2429                requested_action: meta_str("requested_action"),
2430                content: evidence
2431                    .get("content")
2432                    .and_then(Value::as_str)
2433                    .map(str::to_string),
2434                created_at: meta_str("created_at"),
2435                metadata,
2436                evidence: evidence.clone(),
2437            });
2438        }
2439        Ok(handoffs)
2440    }
2441
2442    pub async fn feedback(&self, options: FeedbackOptions) -> Result<Value> {
2443        let run_id = resolve_helper_run_id(options.run_id, self.transport.run_id(), "feedback")?;
2444        self.control
2445            .submit_feedback(prune_nulls(json!({
2446                "run_id": run_id,
2447                "handoff_id": require_non_empty_string(options.handoff_id, "handoff_id")?,
2448                "verdict": require_non_empty_string(options.verdict, "verdict")?,
2449                "comments": options.comments,
2450                "from_agent_id": options.from_agent_id,
2451                "metadata_json": encode_optional_json(options.metadata.as_ref())?,
2452                "user_id": options.user_id,
2453            })))
2454            .await
2455    }
2456
2457    async fn wait_for_ingest_job(
2458        &self,
2459        run_id: &str,
2460        job_id: &str,
2461        timeout_ms: u64,
2462        poll_interval_ms: u64,
2463    ) -> Result<Value> {
2464        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
2465        loop {
2466            let job = self
2467                .control
2468                .get_ingest_job(json!({ "run_id": run_id, "job_id": job_id }))
2469                .await?;
2470            if job
2471                .get("done")
2472                .and_then(|value| value.as_bool())
2473                .unwrap_or(false)
2474            {
2475                return Ok(job);
2476            }
2477            if Instant::now() >= deadline {
2478                return Err(SdkError::TransportError {
2479                    kind: TransportFailureKind::DeadlineExceeded,
2480                    message: format!("timed out waiting for ingest job {}", job_id),
2481                });
2482            }
2483            sleep(Duration::from_millis(poll_interval_ms)).await;
2484        }
2485    }
2486}
2487
2488#[derive(Clone)]
2489pub struct AuthClient {
2490    transport: Arc<TransportEngine>,
2491}
2492
2493impl AuthClient {
2494    pub async fn health(&self) -> Result<Value> {
2495        self.transport.invoke("auth.health", json!({})).await
2496    }
2497
2498    pub fn set_api_key(&self, api_key: Option<String>) {
2499        self.transport.set_api_key(api_key);
2500    }
2501
2502    pub fn set_token(&self, token: Option<String>) {
2503        self.set_api_key(token);
2504    }
2505
2506    pub fn set_run_id(&self, run_id: Option<String>) {
2507        self.transport.set_run_id(run_id);
2508    }
2509}
2510
2511macro_rules! define_auth_payload_methods {
2512    ($($name:ident => $op_key:literal),+ $(,)?) => {
2513        impl AuthClient {
2514            $(
2515                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2516                    self.transport.invoke_serialized($op_key, payload).await
2517                }
2518            )+
2519        }
2520    };
2521}
2522
2523define_auth_payload_methods!(
2524    create_user => "auth.create_user",
2525    rotate_user_api_key => "auth.rotate_user_api_key",
2526    revoke_user_api_key => "auth.revoke_user_api_key",
2527    list_users => "auth.list_users",
2528    get_user => "auth.get_user",
2529    delete_user => "auth.delete_user"
2530);
2531
2532#[derive(Clone)]
2533pub struct CoreClient {
2534    transport: Arc<TransportEngine>,
2535}
2536
2537macro_rules! define_core_payload_methods {
2538    ($($name:ident => $op_key:literal),+ $(,)?) => {
2539        impl CoreClient {
2540            $(
2541                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2542                    self.transport.invoke_serialized($op_key, payload).await
2543                }
2544            )+
2545        }
2546    };
2547}
2548
2549define_core_payload_methods!(
2550    insert => "core.insert",
2551    batch_insert => "core.batch_insert",
2552    search => "core.search",
2553    delete_node => "core.delete_node",
2554    delete_run => "core.delete_run",
2555    create_session => "core.create_session",
2556    snapshot_session => "core.snapshot_session",
2557    load_session => "core.load_session",
2558    commit_session => "core.commit_session",
2559    drop_session => "core.drop_session",
2560    write_memory => "core.write_memory",
2561    read_memory => "core.read_memory",
2562    add_memory => "core.add_memory",
2563    get_memory => "core.get_memory",
2564    clear_memory => "core.clear_memory",
2565    grant_permission => "core.grant_permission",
2566    revoke_permission => "core.revoke_permission",
2567    check_permission => "core.check_permission",
2568    unsubscribe_events => "core.unsubscribe_events"
2569);
2570
2571impl CoreClient {
2572    pub async fn subscribe_events<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2573        self.transport.invoke_stream_serialized("core.subscribe_events", payload).await
2574    }
2575
2576    pub async fn watch_memory<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2577        self.transport.invoke_stream_serialized("core.watch_memory", payload).await
2578    }
2579
2580    pub async fn list_subscriptions(&self) -> Result<Value> {
2581        self.transport
2582            .invoke("core.list_subscriptions", json!({}))
2583            .await
2584    }
2585
2586    pub async fn storage_stats(&self) -> Result<Value> {
2587        self.transport.invoke("core.storage_stats", json!({})).await
2588    }
2589
2590    pub async fn trigger_compaction(&self) -> Result<Value> {
2591        self.transport
2592            .invoke("core.trigger_compaction", json!({}))
2593            .await
2594    }
2595}
2596
2597#[derive(Clone)]
2598pub struct ControlClient {
2599    transport: Arc<TransportEngine>,
2600}
2601
2602macro_rules! define_control_payload_methods {
2603    ($($name:ident => $op_key:literal),+ $(,)?) => {
2604        impl ControlClient {
2605            $(
2606                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2607                    self.transport.invoke_serialized($op_key, payload).await
2608                }
2609            )+
2610        }
2611    };
2612}
2613
2614define_control_payload_methods!(
2615    register_agent => "control.register_agent",
2616    agent_heartbeat => "control.agent_heartbeat",
2617    context_snapshot => "control.context_snapshot",
2618    link_run => "control.link_run",
2619    unlink_run => "control.unlink_run",
2620    ingest => "control.ingest",
2621    batch_insert => "control.batch_insert",
2622    get_ingest_job => "control.get_ingest_job",
2623    get_run_ingest_stats => "control.get_run_ingest_stats",
2624    query => "control.query",
2625    diagnose => "control.diagnose",
2626    delete_run => "control.delete_run",
2627    reflect => "control.reflect",
2628    lessons => "control.lessons",
2629    delete_lesson => "control.delete_lesson",
2630    context => "control.context",
2631    archive_block => "control.archive_block",
2632    dereference => "control.dereference",
2633    memory_health => "control.memory_health",
2634    checkpoint => "control.checkpoint",
2635    list_agents => "control.list_agents",
2636    create_handoff => "control.create_handoff",
2637    submit_feedback => "control.submit_feedback",
2638    circuit_break => "control.circuit_break",
2639    record_outcome => "control.record_outcome",
2640    record_step_outcome => "control.record_step_outcome",
2641    surface_strategies => "control.surface_strategies",
2642    create_session => "control.create_session",
2643    get_session => "control.get_session",
2644    close_session => "control.close_session",
2645    set_prompt => "control.set_prompt",
2646    get_prompt => "control.get_prompt",
2647    list_prompt_versions => "control.list_prompt_versions",
2648    activate_prompt_version => "control.activate_prompt_version",
2649    optimize_prompt => "control.optimize_prompt",
2650    get_prompt_diff => "control.get_prompt_diff",
2651    create_project => "control.create_project",
2652    get_project => "control.get_project",
2653    list_projects => "control.list_projects",
2654    update_project => "control.update_project",
2655    delete_project => "control.delete_project",
2656    create_agent_definition => "control.create_agent_definition",
2657    get_agent_definition => "control.get_agent_definition",
2658    list_agent_definitions => "control.list_agent_definitions",
2659    update_agent_definition => "control.update_agent_definition",
2660    delete_agent_definition => "control.delete_agent_definition",
2661    list_run_history => "control.list_run_history",
2662    get_run_history => "control.get_run_history",
2663    create_skill => "control.create_skill",
2664    get_skill => "control.get_skill",
2665    list_skills => "control.list_skills",
2666    update_skill => "control.update_skill",
2667    delete_skill => "control.delete_skill",
2668    list_skill_versions => "control.list_skill_versions",
2669    activate_skill_version => "control.activate_skill_version",
2670    optimize_skill => "control.optimize_skill",
2671    get_skill_diff => "control.get_skill_diff",
2672);
2673
2674/// Raw low-level control-plane surface, reached via [`Client::advanced`].
2675/// Functionally identical to the deprecated flat delegates on `Client`:
2676/// every operation takes a free-form serializable payload and returns raw
2677/// JSON. The typed helpers stay on `Client` itself.
2678#[derive(Clone)]
2679pub struct AdvancedClient {
2680    control: ControlClient,
2681}
2682
2683macro_rules! define_advanced_delegates {
2684    ($($name:ident),+ $(,)?) => {
2685        impl AdvancedClient {
2686            $(
2687                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2688                    self.control.$name(payload).await
2689                }
2690            )+
2691        }
2692    };
2693}
2694
2695// Mirror the full raw control surface (same set as ControlClient) so
2696// `client.advanced().<op>(payload)` is always a valid way to reach an op.
2697define_advanced_delegates!(
2698    register_agent, agent_heartbeat, context_snapshot,
2699    link_run, unlink_run,
2700    ingest, batch_insert, get_ingest_job, get_run_ingest_stats,
2701    query, diagnose,
2702    delete_run, reflect,
2703    lessons, delete_lesson,
2704    context, archive_block, dereference, memory_health,
2705    checkpoint, list_agents,
2706    create_handoff, submit_feedback,
2707    circuit_break, record_outcome, record_step_outcome, surface_strategies,
2708    create_session, get_session, close_session,
2709    set_prompt, get_prompt, list_prompt_versions,
2710    activate_prompt_version, optimize_prompt, get_prompt_diff,
2711    create_project, get_project, list_projects, update_project, delete_project,
2712    create_agent_definition, get_agent_definition, list_agent_definitions,
2713    update_agent_definition, delete_agent_definition,
2714    list_run_history, get_run_history,
2715    create_skill, get_skill, list_skills, update_skill, delete_skill,
2716    list_skill_versions, activate_skill_version, optimize_skill, get_skill_diff,
2717);
2718
2719impl AdvancedClient {
2720    pub async fn subscribe<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2721        self.control.subscribe(payload).await
2722    }
2723}
2724
2725macro_rules! define_client_control_delegates {
2726    ($($name:ident),+ $(,)?) => {
2727        impl Client {
2728            $(
2729                #[deprecated(
2730                    since = "0.12.0",
2731                    note = "raw control ops moved to client.advanced().<op>(payload) — the flat Client delegate will be removed in a future major release"
2732                )]
2733                pub async fn $name<T: Serialize>(&self, payload: T) -> Result<Value> {
2734                    self.control.$name(payload).await
2735                }
2736            )+
2737        }
2738    };
2739}
2740
2741define_client_control_delegates!(
2742    agent_heartbeat, context_snapshot,
2743    link_run, unlink_run,
2744    ingest, batch_insert, get_ingest_job, get_run_ingest_stats,
2745    query,
2746    delete_run,
2747    lessons, delete_lesson,
2748    context,
2749    create_handoff, submit_feedback,
2750    create_session, get_session, close_session,
2751    set_prompt, get_prompt, list_prompt_versions,
2752    activate_prompt_version, get_prompt_diff,
2753    create_project, get_project, list_projects, update_project, delete_project,
2754    create_agent_definition, get_agent_definition, list_agent_definitions,
2755    update_agent_definition, delete_agent_definition,
2756    list_run_history, get_run_history,
2757    create_skill, get_skill, list_skills, update_skill, delete_skill,
2758    list_skill_versions, activate_skill_version, get_skill_diff,
2759);
2760
2761impl Client {
2762    pub async fn subscribe<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2763        self.control.subscribe(payload).await
2764    }
2765}
2766
2767impl ControlClient {
2768    pub async fn subscribe<T: Serialize>(&self, payload: T) -> Result<ValueStream> {
2769        self.transport.invoke_stream_serialized("control.subscribe", payload).await
2770    }
2771}
2772
2773fn parse_sse_byte_stream(
2774    byte_stream: impl Stream<Item = reqwest::Result<bytes::Bytes>> + Send + 'static,
2775) -> impl Stream<Item = Result<Value>> + Send {
2776    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Value>>(64);
2777    tokio::spawn(async move {
2778        tokio::pin!(byte_stream);
2779        let mut buffer = String::new();
2780        while let Some(chunk_result) = byte_stream.next().await {
2781            match chunk_result {
2782                Ok(chunk) => {
2783                    buffer.push_str(&String::from_utf8_lossy(&chunk));
2784                    while let Some(newline_pos) = buffer.find('\n') {
2785                        let line = buffer[..newline_pos].trim().to_string();
2786                        buffer = buffer[newline_pos + 1..].to_string();
2787                        if line.is_empty() || line.starts_with(':') {
2788                            continue;
2789                        }
2790                        let data = if line.starts_with("data: ") {
2791                            &line[6..]
2792                        } else {
2793                            &line
2794                        };
2795                        let value = match serde_json::from_str::<Value>(data) {
2796                            Ok(value) => Ok(value),
2797                            Err(_) => Ok(Value::String(data.to_string())),
2798                        };
2799                        if tx.send(value).await.is_err() {
2800                            return;
2801                        }
2802                    }
2803                }
2804                Err(e) => {
2805                    let _ = tx
2806                        .send(Err(SdkError::TransportError {
2807                            kind: TransportFailureKind::Io,
2808                            message: e.to_string(),
2809                        }))
2810                        .await;
2811                    return;
2812                }
2813            }
2814        }
2815        // Flush remaining buffer
2816        let remaining = buffer.trim().to_string();
2817        if !remaining.is_empty() && !remaining.starts_with(':') {
2818            let data = if remaining.starts_with("data: ") {
2819                &remaining[6..]
2820            } else {
2821                &remaining
2822            };
2823            let value = match serde_json::from_str::<Value>(data) {
2824                Ok(value) => Ok(value),
2825                Err(_) => Ok(Value::String(data.to_string())),
2826            };
2827            let _ = tx.send(value).await;
2828        }
2829    });
2830    tokio_stream::wrappers::ReceiverStream::new(rx)
2831}
2832
2833/// Parse `metadata_json` / `entry_json` strings on a pubsub event dict into
2834/// native `metadata` / `entry` objects, and drop proto3 default-value fields
2835/// that aren't meaningful for the event kind (e.g. `created_at: 0` on a
2836/// `node.deleted` or `subscribed` event). The server sends JSON-encoded
2837/// strings to keep the proto flat; this is the symmetric un-packer so every
2838/// SDK caller sees one native shape regardless of transport.
2839fn hydrate_pubsub_event(value: &mut Value) {
2840    let Some(obj) = value.as_object_mut() else {
2841        return;
2842    };
2843
2844    if let Some(Value::String(raw)) = obj.remove("metadata_json") {
2845        let parsed = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
2846        obj.insert("metadata".to_string(), parsed);
2847    }
2848
2849    if let Some(Value::String(raw)) = obj.remove("entry_json") {
2850        let parsed = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
2851        obj.insert("entry".to_string(), parsed);
2852    }
2853
2854    // Strip proto3 scalar defaults that only exist because the schema is flat.
2855    // The `type` discriminator tells the caller which fields are meaningful;
2856    // the rest just clutter the payload.
2857    let event_type = obj
2858        .get("type")
2859        .and_then(Value::as_str)
2860        .map(str::to_string)
2861        .unwrap_or_default();
2862    let keep: &[&str] = match event_type.as_str() {
2863        "subscribed" => &["type", "subscription_id"],
2864        "node.inserted" | "node.updated" => {
2865            &["type", "node_id", "run_id", "metadata", "created_at", "updated_at"]
2866        }
2867        "node.deleted" => &["type", "node_id"],
2868        "memory.added" => &["type", "session_id", "entry"],
2869        _ => return,
2870    };
2871    obj.retain(|k, _| keep.iter().any(|known| *known == k));
2872}
2873
2874fn prune_nulls(value: Value) -> Value {
2875    match value {
2876        Value::Object(map) => Value::Object(
2877            map.into_iter()
2878                .filter_map(|(key, value)| {
2879                    let cleaned = prune_nulls(value);
2880                    if cleaned.is_null() {
2881                        None
2882                    } else {
2883                        Some((key, cleaned))
2884                    }
2885                })
2886                .collect::<Map<String, Value>>(),
2887        ),
2888        Value::Array(items) => Value::Array(items.into_iter().map(prune_nulls).collect()),
2889        other => other,
2890    }
2891}
2892
2893fn resolve_helper_run_id(
2894    explicit: Option<String>,
2895    fallback: Option<String>,
2896    helper_name: &str,
2897) -> Result<String> {
2898    let candidate = explicit.or(fallback).unwrap_or_default();
2899    if candidate.trim().is_empty() {
2900        return Err(SdkError::ValidationError(format!(
2901            "{} requires run_id or a client default run_id",
2902            helper_name
2903        )));
2904    }
2905    Ok(candidate)
2906}
2907
2908/// Write-time lesson sharing scopes accepted by the ingest contract. "org" is
2909/// deliberately absent: org scope is reached by server-side promotion of
2910/// validated global lessons, never by direct write.
2911const SHARE_WRITE_SCOPES: &[&str] = &["run", "session", "global"];
2912
2913fn resolve_share_scope(
2914    share: Option<String>,
2915    lesson_scope: Option<String>,
2916) -> Result<Option<String>> {
2917    let Some(share) = share else {
2918        return Ok(lesson_scope);
2919    };
2920    let value = share.trim().to_lowercase();
2921    if value == "org" {
2922        return Err(SdkError::ValidationError(
2923            "share='org' is not writable directly — org scope is promotion-gated. \
2924             Write with share='global' and let validated lessons promote to org."
2925                .to_string(),
2926        ));
2927    }
2928    if !SHARE_WRITE_SCOPES.contains(&value.as_str()) {
2929        return Err(SdkError::ValidationError(
2930            "share must be one of: run, session, global".to_string(),
2931        ));
2932    }
2933    if let Some(lesson_scope) = lesson_scope {
2934        if lesson_scope != value {
2935            return Err(SdkError::ValidationError(
2936                "share conflicts with lesson_scope; pass only one of them".to_string(),
2937            ));
2938        }
2939    }
2940    Ok(Some(value))
2941}
2942
2943/// Best-effort parse of a query-evidence metadata payload. Evidence carries
2944/// metadata as a JSON-encoded string (`metadata_json`) so the proto stays
2945/// flat; be defensive about shape.
2946fn evidence_metadata_value(evidence: &Value) -> Value {
2947    let raw = evidence
2948        .get("metadata_json")
2949        .or_else(|| evidence.get("metadataJson"));
2950    match raw {
2951        Some(Value::Object(map)) => Value::Object(map.clone()),
2952        Some(Value::String(raw)) if !raw.trim().is_empty() => {
2953            match serde_json::from_str::<Value>(raw) {
2954                Ok(parsed @ Value::Object(_)) => parsed,
2955                _ => json!({}),
2956            }
2957        }
2958        _ => json!({}),
2959    }
2960}
2961
2962fn require_non_empty_string(value: String, field_name: &str) -> Result<String> {
2963    if value.trim().is_empty() {
2964        return Err(SdkError::ValidationError(format!(
2965            "{} is required",
2966            field_name
2967        )));
2968    }
2969    Ok(value)
2970}
2971
2972fn encode_optional_json(value: Option<&Value>) -> Result<String> {
2973    match value {
2974        Some(value) => serde_json::to_string(value).map_err(|err| {
2975            SdkError::ValidationError(format!("failed to serialize helper json field: {}", err))
2976        }),
2977        None => Ok(String::new()),
2978    }
2979}
2980
2981fn encode_string_vec(values: &[String]) -> Result<String> {
2982    if values.is_empty() {
2983        return Ok(String::new());
2984    }
2985    serde_json::to_string(values).map_err(|err| {
2986        SdkError::ValidationError(format!("failed to serialize helper string list: {}", err))
2987    })
2988}
2989
2990fn generate_helper_id(prefix: &str) -> String {
2991    use std::time::{SystemTime, UNIX_EPOCH};
2992
2993    let millis = SystemTime::now()
2994        .duration_since(UNIX_EPOCH)
2995        .unwrap_or_default()
2996        .as_millis();
2997    format!("{}-{}", prefix, millis)
2998}
2999
3000fn ensure_object_payload(payload: Value) -> Result<Value> {
3001    match payload {
3002        Value::Null => Ok(json!({})),
3003        Value::Object(_) => Ok(payload),
3004        _ => Err(SdkError::ValidationError(
3005            "payload must serialize to a JSON object".to_string(),
3006        )),
3007    }
3008}
3009
3010fn decode_grpc_request<T: DeserializeOwned>(op_key: &str, payload: Value) -> Result<T> {
3011    serde_json::from_value(payload).map_err(|e| {
3012        SdkError::ValidationError(format!(
3013            "invalid gRPC request payload for {}: {}",
3014            op_key, e
3015        ))
3016    })
3017}
3018
3019fn encode_grpc_response<T: Serialize>(op_key: &str, response: T) -> Result<Value> {
3020    serde_json::to_value(response).map_err(|e| {
3021        SdkError::ServerError(format!(
3022            "failed to serialize gRPC response for {}: {}",
3023            op_key, e
3024        ))
3025    })
3026}
3027
3028fn decode_batch_insert_payload(
3029    payload: Value,
3030) -> Result<Vec<crate::proto::mubit::v1::InsertRequest>> {
3031    let payload = ensure_object_payload(payload)?;
3032    let mut extracted: Option<&Value> = None;
3033    if let Some(map) = payload.as_object() {
3034        for key in ["items", "requests", "nodes"] {
3035            if let Some(value) = map.get(key) {
3036                extracted = Some(value);
3037                break;
3038            }
3039        }
3040    }
3041
3042    if let Some(Value::Array(items)) = extracted {
3043        if items.is_empty() {
3044            return Err(SdkError::ValidationError(
3045                "batch_insert gRPC payload cannot provide an empty items list".to_string(),
3046            ));
3047        }
3048        return items
3049            .iter()
3050            .cloned()
3051            .map(|item| decode_grpc_request("core.batch_insert", item))
3052            .collect();
3053    }
3054
3055    if extracted.is_some() {
3056        return Err(SdkError::ValidationError(
3057            "batch_insert gRPC payload items/requests/nodes must be an array".to_string(),
3058        ));
3059    }
3060
3061    let single = decode_grpc_request("core.batch_insert", payload)?;
3062    Ok(vec![single])
3063}
3064
3065fn value_to_param(value: &Value) -> Option<String> {
3066    match value {
3067        Value::String(v) => Some(v.clone()),
3068        Value::Number(v) => Some(v.to_string()),
3069        Value::Bool(v) => Some(v.to_string()),
3070        _ => None,
3071    }
3072}
3073
3074fn value_to_query(value: &Value) -> Option<String> {
3075    match value {
3076        Value::String(v) => Some(v.clone()),
3077        Value::Number(v) => Some(v.to_string()),
3078        Value::Bool(v) => Some(v.to_string()),
3079        Value::Array(items) => {
3080            let rendered: Vec<String> = items.iter().filter_map(value_to_query).collect();
3081            if rendered.is_empty() {
3082                None
3083            } else {
3084                Some(rendered.join(","))
3085            }
3086        }
3087        _ => None,
3088    }
3089}
3090
3091fn derive_http_and_grpc(endpoint: &str) -> Result<(String, String, bool)> {
3092    let endpoint = if endpoint.contains("://") {
3093        endpoint.to_string()
3094    } else {
3095        format!("http://{}", endpoint)
3096    };
3097
3098    let parsed = Url::parse(&endpoint).map_err(|e| {
3099        SdkError::ValidationError(format!("invalid endpoint '{}': {}", endpoint, e))
3100    })?;
3101
3102    let host = parsed.host_str().ok_or_else(|| {
3103        SdkError::ValidationError(format!("endpoint '{}' missing host", endpoint))
3104    })?;
3105
3106    let scheme = parsed.scheme();
3107    let port = parsed.port_or_known_default().ok_or_else(|| {
3108        SdkError::ValidationError(format!(
3109            "endpoint '{}' missing known default port",
3110            endpoint
3111        ))
3112    })?;
3113
3114    let default_port = match scheme {
3115        "https" => 443,
3116        _ => 80,
3117    };
3118
3119    let http_endpoint = if port == default_port {
3120        format!("{}://{}", scheme, host)
3121    } else {
3122        format!("{}://{}:{}", scheme, host, port)
3123    };
3124
3125    let grpc_endpoint = format!("{}:{}", host, port);
3126    let grpc_tls = scheme.eq_ignore_ascii_case("https");
3127
3128    Ok((http_endpoint, grpc_endpoint, grpc_tls))
3129}
3130
3131fn normalize_http_endpoint(endpoint: &str) -> Result<String> {
3132    let endpoint = if endpoint.contains("://") {
3133        endpoint.to_string()
3134    } else {
3135        format!("http://{}", endpoint)
3136    };
3137
3138    let parsed = Url::parse(&endpoint).map_err(|e| {
3139        SdkError::ValidationError(format!("invalid http_endpoint '{}': {}", endpoint, e))
3140    })?;
3141
3142    let host = parsed.host_str().ok_or_else(|| {
3143        SdkError::ValidationError(format!("http_endpoint '{}' missing host", endpoint))
3144    })?;
3145
3146    let scheme = parsed.scheme();
3147    let port = parsed.port_or_known_default().ok_or_else(|| {
3148        SdkError::ValidationError(format!(
3149            "http_endpoint '{}' missing known default port",
3150            endpoint
3151        ))
3152    })?;
3153
3154    let default_port = if scheme.eq_ignore_ascii_case("https") {
3155        443
3156    } else {
3157        80
3158    };
3159
3160    let normalized = if port == default_port {
3161        format!("{}://{}", scheme, host)
3162    } else {
3163        format!("{}://{}:{}", scheme, host, port)
3164    };
3165
3166    Ok(normalized)
3167}
3168
3169fn normalize_grpc_endpoint(endpoint: &str) -> Result<(String, bool)> {
3170    if endpoint.contains("://") {
3171        let parsed = Url::parse(endpoint).map_err(|e| {
3172            SdkError::ValidationError(format!("invalid grpc_endpoint '{}': {}", endpoint, e))
3173        })?;
3174        let host = parsed.host_str().ok_or_else(|| {
3175            SdkError::ValidationError(format!("grpc_endpoint '{}' missing host", endpoint))
3176        })?;
3177        let port = parsed.port_or_known_default().ok_or_else(|| {
3178            SdkError::ValidationError(format!(
3179                "grpc_endpoint '{}' missing known default port",
3180                endpoint
3181            ))
3182        })?;
3183        return Ok((
3184            format!("{}:{}", host, port),
3185            parsed.scheme().eq_ignore_ascii_case("https")
3186                || parsed.scheme().eq_ignore_ascii_case("grpcs"),
3187        ));
3188    }
3189
3190    let endpoint = endpoint.trim();
3191    if endpoint.is_empty() {
3192        return Err(SdkError::ValidationError(
3193            "grpc_endpoint cannot be empty".to_string(),
3194        ));
3195    }
3196
3197    if let Some((host, port_text)) = endpoint.rsplit_once(':') {
3198        if !host.trim().is_empty() {
3199            if let Ok(port) = port_text.parse::<u16>() {
3200                return Ok((format!("{}:{}", host, port), port == 443));
3201            }
3202        }
3203        return Ok((endpoint.to_string(), false));
3204    }
3205
3206    Ok((format!("{}:50051", endpoint), false))
3207}
3208
3209fn map_grpc_connect_error(error: tonic::transport::Error, endpoint: &str) -> SdkError {
3210    let lower = error.to_string().to_lowercase();
3211    let kind = if lower.contains("deadline") || lower.contains("timed out") {
3212        TransportFailureKind::DeadlineExceeded
3213    } else if lower.contains("connection reset") {
3214        TransportFailureKind::ConnectionReset
3215    } else if lower.contains("dns")
3216        || lower.contains("refused")
3217        || lower.contains("unavailable")
3218        || lower.contains("not connected")
3219    {
3220        TransportFailureKind::Unavailable
3221    } else {
3222        TransportFailureKind::Io
3223    };
3224
3225    SdkError::TransportError {
3226        kind,
3227        message: format!("failed to connect to gRPC endpoint {}: {}", endpoint, error),
3228    }
3229}
3230
3231fn map_grpc_status(status: Status) -> SdkError {
3232    let message = status.message().to_string();
3233    match status.code() {
3234        Code::Unauthenticated | Code::PermissionDenied => SdkError::AuthError(message),
3235        Code::InvalidArgument
3236        | Code::NotFound
3237        | Code::AlreadyExists
3238        | Code::FailedPrecondition
3239        | Code::OutOfRange => SdkError::ValidationError(message),
3240        Code::Unavailable => SdkError::TransportError {
3241            kind: TransportFailureKind::Unavailable,
3242            message,
3243        },
3244        Code::DeadlineExceeded => SdkError::TransportError {
3245            kind: TransportFailureKind::DeadlineExceeded,
3246            message,
3247        },
3248        Code::Unimplemented => SdkError::TransportError {
3249            kind: TransportFailureKind::Unimplemented,
3250            message,
3251        },
3252        Code::Cancelled => SdkError::TransportError {
3253            kind: TransportFailureKind::Io,
3254            message,
3255        },
3256        Code::Unknown | Code::Internal => {
3257            let lower = message.to_lowercase();
3258            if lower.contains("connection reset") {
3259                SdkError::TransportError {
3260                    kind: TransportFailureKind::ConnectionReset,
3261                    message,
3262                }
3263            } else if lower.contains("transport")
3264                || lower.contains("broken pipe")
3265                || lower.contains("io error")
3266            {
3267                SdkError::TransportError {
3268                    kind: TransportFailureKind::Io,
3269                    message,
3270                }
3271            } else {
3272                SdkError::ServerError(message)
3273            }
3274        }
3275        _ => SdkError::ServerError(message),
3276    }
3277}
3278
3279fn map_transport_error(error: reqwest::Error, message: String) -> SdkError {
3280    let lower = error.to_string().to_lowercase();
3281    let kind = if error.is_timeout() {
3282        TransportFailureKind::DeadlineExceeded
3283    } else if error.is_connect() {
3284        TransportFailureKind::Unavailable
3285    } else if lower.contains("connection reset") {
3286        TransportFailureKind::ConnectionReset
3287    } else if error.is_request() || error.is_body() {
3288        TransportFailureKind::Io
3289    } else {
3290        TransportFailureKind::Other
3291    };
3292
3293    SdkError::TransportError {
3294        kind,
3295        message: format!("{}: {}", message, error),
3296    }
3297}
3298
3299fn map_http_error(status: u16, body: String) -> SdkError {
3300    match status {
3301        401 | 403 => SdkError::AuthError(body),
3302        400 | 404 | 409 | 422 => SdkError::ValidationError(body),
3303        501 => SdkError::UnsupportedFeatureError(body),
3304        _ => SdkError::ServerError(body),
3305    }
3306}
3307
3308fn http_method_label(method: HttpMethod) -> &'static str {
3309    match method {
3310        HttpMethod::Get => "GET",
3311        HttpMethod::Post => "POST",
3312        HttpMethod::Delete => "DELETE",
3313    }
3314}
3315
3316#[cfg(test)]
3317mod ergonomics_tests {
3318    use super::*;
3319
3320    fn test_client() -> Client {
3321        // Unroutable local endpoint: these tests never reach the network.
3322        Client::new(ClientConfig::new("http://127.0.0.1:9")).expect("client construction")
3323    }
3324
3325    #[test]
3326    fn run_scope_sets_and_restores_previous_run_id() {
3327        let client = test_client();
3328        client.set_run_id(Some("outer".to_string()));
3329        {
3330            let scope = client.run_scope("inner");
3331            assert_eq!(scope.run_id(), "inner");
3332            assert_eq!(client.transport.run_id().as_deref(), Some("inner"));
3333        }
3334        assert_eq!(client.transport.run_id().as_deref(), Some("outer"));
3335    }
3336
3337    #[test]
3338    fn run_scope_restores_none_when_no_previous_run_id() {
3339        let client = test_client();
3340        assert_eq!(client.transport.run_id(), None);
3341        {
3342            let _scope = client.run_scope("inner");
3343            assert_eq!(client.transport.run_id().as_deref(), Some("inner"));
3344        }
3345        assert_eq!(client.transport.run_id(), None);
3346    }
3347
3348    #[test]
3349    fn remember_builder_sets_share_and_occurrence_time() {
3350        let options = RememberBuilder::new("content")
3351            .share("global")
3352            .occurrence_time(1_720_000_000)
3353            .build();
3354        assert_eq!(options.share.as_deref(), Some("global"));
3355        assert_eq!(options.occurrence_time, Some(1_720_000_000));
3356    }
3357
3358    #[test]
3359    fn resolve_share_scope_accepts_writable_scopes() {
3360        for scope in ["run", "session", "global"] {
3361            let resolved = resolve_share_scope(Some(scope.to_string()), None)
3362                .expect("writable scope accepted");
3363            assert_eq!(resolved.as_deref(), Some(scope));
3364        }
3365        // No share: lesson_scope passes through untouched (including "org",
3366        // which stays a raw passthrough for backward compatibility).
3367        let resolved = resolve_share_scope(None, Some("session".to_string())).expect("passthrough");
3368        assert_eq!(resolved.as_deref(), Some("session"));
3369    }
3370
3371    #[test]
3372    fn resolve_share_scope_rejects_org_and_unknown_and_conflicts() {
3373        let err = resolve_share_scope(Some("org".to_string()), None)
3374            .expect_err("org is promotion-gated");
3375        assert!(matches!(err, SdkError::ValidationError(message) if message.contains("promotion")));
3376
3377        assert!(resolve_share_scope(Some("galaxy".to_string()), None).is_err());
3378        assert!(
3379            resolve_share_scope(Some("run".to_string()), Some("session".to_string())).is_err()
3380        );
3381    }
3382
3383    #[tokio::test]
3384    async fn remember_rejects_org_share_before_any_transport_call() {
3385        let client = test_client();
3386        let options = RememberBuilder::new("content")
3387            .share("org")
3388            .session(SessionScope {
3389                run_id: Some("run-1".to_string()),
3390                ..SessionScope::default()
3391            })
3392            .build();
3393        let err = client
3394            .remember(options)
3395            .await
3396            .expect_err("org share must fail validation");
3397        assert!(matches!(err, SdkError::ValidationError(message) if message.contains("promotion")));
3398    }
3399
3400    #[test]
3401    fn evidence_metadata_value_parses_json_string_and_object() {
3402        let from_string = evidence_metadata_value(&json!({
3403            "metadata_json": "{\"to_agent_id\":\"executor\",\"verdict\":\"approve\"}",
3404        }));
3405        assert_eq!(
3406            from_string.get("to_agent_id").and_then(Value::as_str),
3407            Some("executor")
3408        );
3409
3410        let from_object = evidence_metadata_value(&json!({
3411            "metadataJson": {"handoff_id": "h-1"},
3412        }));
3413        assert_eq!(
3414            from_object.get("handoff_id").and_then(Value::as_str),
3415            Some("h-1")
3416        );
3417
3418        assert_eq!(evidence_metadata_value(&json!({})), json!({}));
3419        assert_eq!(
3420            evidence_metadata_value(&json!({"metadata_json": "not-json"})),
3421            json!({})
3422        );
3423    }
3424
3425    #[test]
3426    fn advanced_namespace_is_reachable() {
3427        let client = test_client();
3428        // Compile-time + shape check: the advanced namespace exposes the raw
3429        // control surface without consuming the client.
3430        let _advanced: &AdvancedClient = client.advanced();
3431    }
3432}