1pub mod contract;
2pub mod learn;
3
4pub 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
38pub 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>, 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 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
207fn 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 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.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.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.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 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 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 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
991pub struct RunScope {
994 client: Client,
995 run_id: String,
996 previous: Option<String>,
997 restored: bool,
998}
999
1000impl RunScope {
1001 pub fn run_id(&self) -> &str {
1003 &self.run_id
1004 }
1005
1006 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 pub occurrence_time: Option<i64>,
1065 pub env_tags: Vec<String>,
1068 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#[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#[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#[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
1155pub 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 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 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 pub min_timestamp: Option<i64>,
1287 pub max_timestamp: Option<i64>,
1289 pub budget: Option<String>,
1291 pub rank_by: Option<String>,
1293 pub explain: Option<bool>,
1295 pub prefer_current_run: Option<bool>,
1300 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 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#[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
1421pub 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 pub verified_in_production: bool,
1642 pub entry_ids: Vec<String>,
1646 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#[derive(Clone, Debug, Default)]
1675pub struct OptimizePromptOptions {
1676 pub agent_id: String,
1677 pub auto_activate: bool,
1682 pub run_id: Option<String>,
1683 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#[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#[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 pub await_verdict: bool,
1802 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#[derive(Clone, Debug)]
1831pub struct ReceiveHandoffsOptions {
1832 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#[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 pub metadata: Value,
1863 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 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 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 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 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 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 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 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#[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
2695define_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 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
2833fn 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 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
2908const 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
2943fn 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 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 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 let _advanced: &AdvancedClient = client.advanced();
3431 }
3432}