Skip to content

Commit bb4e0e5

Browse files
committed
Add reusable Rust SDK relay helpers
1 parent 9bc9630 commit bb4e0e5

10 files changed

Lines changed: 1403 additions & 18 deletions

File tree

packages/sdk-rust/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi
66

77
## [Unreleased]
88

9+
### Added
10+
- Added raw WebSocket event subscriptions with `WsClient::subscribe_raw_events()` and `RawEventReceiver`.
11+
- Added SDK-owned raw event normalization helpers:
12+
- `normalize_inbound_event(...)`
13+
- `normalize_command_invocation(...)`
14+
- `normalize_sender_identity(...)`
15+
- `NormalizedInboundEvent`, `NormalizedCommandInvocation`, `NormalizedEventKind`, `SenderKind`, and `RelayPriority`
16+
- Added identity helpers `agent_name_eq(...)` and `is_self_name(...)`.
17+
- Added `AgentRegistrationClient::registered_agent_client(...)` for cached/spawned token registration followed by agent-client construction.
18+
- Added idempotent channel startup helpers `AgentClient::ensure_joined_channel(...)` and `AgentClient::ensure_joined_channels(...)`.
19+
- Added `DmParticipantsCache` for bounded workspace DM participant lookup caching.
20+
921
### Changed
1022
- Made `agent.spawn_requested` websocket parsing tolerant of missing or `null` `task`, `channel`, and `already_existed` fields.
1123
- Kept `AgentSpawnRequestedPayload.task` as a `String` for API compatibility by deserializing missing or `null` values to an empty string.

packages/sdk-rust/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,17 @@ agent.dm("other-agent", "Private message", None).await?;
8383
agent.create_channel(CreateChannelRequest {
8484
name: "my-channel".to_string(),
8585
topic: Some("Channel topic".to_string()),
86+
metadata: None,
8687
}).await?;
8788

8889
agent.join_channel("my-channel").await?;
90+
91+
// Idempotent startup helper: create if needed, then join if needed
92+
agent.ensure_joined_channel(CreateChannelRequest {
93+
name: "general".to_string(),
94+
topic: Some("General discussion".to_string()),
95+
metadata: None,
96+
}).await?;
8997
```
9098

9199
### Real-time Events
@@ -121,6 +129,36 @@ while let Ok(event) = events.recv().await {
121129
}
122130
```
123131

132+
For consumers that need to handle evolving event shapes before converting to typed SDK events, subscribe to raw JSON events and normalize them:
133+
134+
```rust
135+
use relaycast::{normalize_inbound_event, WsClient, WsClientOptions};
136+
137+
let mut ws = WsClient::new(WsClientOptions::new("at_live_xxx"));
138+
let mut raw_events = ws.subscribe_raw_events();
139+
ws.connect().await?;
140+
141+
while let Ok(raw) = raw_events.recv().await {
142+
if let Some(event) = normalize_inbound_event(&raw) {
143+
println!("{} -> {}: {}", event.from, event.target, event.text);
144+
}
145+
}
146+
```
147+
148+
### Registration Helpers
149+
150+
```rust
151+
use relaycast::{AgentRegistrationClient, RelayCast, RelayCastOptions};
152+
153+
let relay = RelayCast::new(RelayCastOptions::new("rk_live_xxx"))?;
154+
let registration = AgentRegistrationClient::new(relay, "codex");
155+
156+
let agent = registration
157+
.registered_agent_client("worker-a", Some("codex"))
158+
.await?;
159+
agent.send("#general", "ready", None, None, None).await?;
160+
```
161+
124162
### Files
125163

126164
```rust

packages/sdk-rust/src/agent.rs

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Agent client for message and channel operations.
22
33
use crate::client::{ClientOptions, HttpClient, RequestOptions};
4-
use crate::error::Result;
4+
use crate::error::{RelayError, Result};
55
use crate::types::*;
66
use crate::ws::{EventReceiver, LifecycleReceiver, WsClient, WsClientOptions};
77

@@ -34,6 +34,17 @@ impl Default for DmOptions {
3434
}
3535
}
3636

37+
/// Result of ensuring a channel exists and is joined.
38+
#[derive(Debug, Clone, PartialEq, Eq)]
39+
pub struct EnsureChannelOutcome {
40+
/// Channel name that was ensured.
41+
pub name: String,
42+
/// Whether the channel was created by this call.
43+
pub created: bool,
44+
/// Whether the agent joined the channel during this call.
45+
pub joined: bool,
46+
}
47+
3748
impl AgentClient {
3849
/// Create a new agent client with the given token.
3950
pub fn new(token: impl Into<String>, base_url: Option<String>) -> Result<Self> {
@@ -314,28 +325,42 @@ impl AgentClient {
314325
// === DMs ===
315326

316327
/// Send a direct message to another agent.
317-
pub async fn dm(&self, agent: &str, text: &str, opts: Option<DmOptions>) -> Result<serde_json::Value> {
328+
pub async fn dm(
329+
&self,
330+
agent: &str,
331+
text: &str,
332+
opts: Option<DmOptions>,
333+
) -> Result<serde_json::Value> {
318334
let opts = opts.unwrap_or_default();
319335
let body = SendDmRequest {
320336
to: agent.to_string(),
321337
text: text.to_string(),
322338
attachments: opts.attachments,
323339
mode: Some(opts.mode),
324340
};
325-
let options = opts.idempotency_key.map(RequestOptions::with_idempotency_key);
341+
let options = opts
342+
.idempotency_key
343+
.map(RequestOptions::with_idempotency_key);
326344
self.client.post("/v1/dm", Some(body), options).await
327345
}
328346

329347
/// Send a direct message to another agent (typed response).
330-
pub async fn dm_typed(&self, agent: &str, text: &str, opts: Option<DmOptions>) -> Result<DmSendResponse> {
348+
pub async fn dm_typed(
349+
&self,
350+
agent: &str,
351+
text: &str,
352+
opts: Option<DmOptions>,
353+
) -> Result<DmSendResponse> {
331354
let opts = opts.unwrap_or_default();
332355
let body = SendDmRequest {
333356
to: agent.to_string(),
334357
text: text.to_string(),
335358
attachments: opts.attachments,
336359
mode: Some(opts.mode),
337360
};
338-
let options = opts.idempotency_key.map(RequestOptions::with_idempotency_key);
361+
let options = opts
362+
.idempotency_key
363+
.map(RequestOptions::with_idempotency_key);
339364
self.client.post("/v1/dm", Some(body), options).await
340365
}
341366

@@ -434,7 +459,10 @@ impl AgentClient {
434459
) -> Result<serde_json::Value> {
435460
let opts = opts.unwrap_or_default();
436461
let mut body = serde_json::Map::new();
437-
body.insert("text".to_string(), serde_json::Value::String(text.to_string()));
462+
body.insert(
463+
"text".to_string(),
464+
serde_json::Value::String(text.to_string()),
465+
);
438466
body.insert(
439467
"mode".to_string(),
440468
serde_json::Value::String(match opts.mode {
@@ -443,9 +471,14 @@ impl AgentClient {
443471
}),
444472
);
445473
if let Some(attachments) = opts.attachments {
446-
body.insert("attachments".to_string(), serde_json::to_value(attachments)?);
474+
body.insert(
475+
"attachments".to_string(),
476+
serde_json::to_value(attachments)?,
477+
);
447478
}
448-
let options = opts.idempotency_key.map(RequestOptions::with_idempotency_key);
479+
let options = opts
480+
.idempotency_key
481+
.map(RequestOptions::with_idempotency_key);
449482
self.client
450483
.post(
451484
&format!("/v1/dm/{}/messages", urlencoding::encode(conversation_id)),
@@ -464,7 +497,10 @@ impl AgentClient {
464497
) -> Result<GroupDmMessageResponse> {
465498
let opts = opts.unwrap_or_default();
466499
let mut body = serde_json::Map::new();
467-
body.insert("text".to_string(), serde_json::Value::String(text.to_string()));
500+
body.insert(
501+
"text".to_string(),
502+
serde_json::Value::String(text.to_string()),
503+
);
468504
body.insert(
469505
"mode".to_string(),
470506
serde_json::Value::String(match opts.mode {
@@ -473,9 +509,14 @@ impl AgentClient {
473509
}),
474510
);
475511
if let Some(attachments) = opts.attachments {
476-
body.insert("attachments".to_string(), serde_json::to_value(attachments)?);
512+
body.insert(
513+
"attachments".to_string(),
514+
serde_json::to_value(attachments)?,
515+
);
477516
}
478-
let options = opts.idempotency_key.map(RequestOptions::with_idempotency_key);
517+
let options = opts
518+
.idempotency_key
519+
.map(RequestOptions::with_idempotency_key);
479520
self.client
480521
.post(
481522
&format!("/v1/dm/{}/messages", urlencoding::encode(conversation_id)),
@@ -544,6 +585,46 @@ impl AgentClient {
544585
self.client.post("/v1/channels", Some(request), None).await
545586
}
546587

588+
/// Ensure a channel exists and this agent is a member.
589+
///
590+
/// `409 Conflict` from either create or join is treated as success, which
591+
/// makes this safe to call during startup.
592+
pub async fn ensure_joined_channel(
593+
&self,
594+
request: CreateChannelRequest,
595+
) -> Result<EnsureChannelOutcome> {
596+
let name = request.name.clone();
597+
let created = match self.create_channel(request).await {
598+
Ok(_) => true,
599+
Err(RelayError::Api { status: 409, .. }) => false,
600+
Err(error) => return Err(error),
601+
};
602+
603+
let joined = match self.join_channel(&name).await {
604+
Ok(_) => true,
605+
Err(RelayError::Api { status: 409, .. }) => false,
606+
Err(error) => return Err(error),
607+
};
608+
609+
Ok(EnsureChannelOutcome {
610+
name,
611+
created,
612+
joined,
613+
})
614+
}
615+
616+
/// Ensure several channels exist and this agent is a member of each.
617+
pub async fn ensure_joined_channels<I>(&self, requests: I) -> Result<Vec<EnsureChannelOutcome>>
618+
where
619+
I: IntoIterator<Item = CreateChannelRequest>,
620+
{
621+
let mut outcomes = Vec::new();
622+
for request in requests {
623+
outcomes.push(self.ensure_joined_channel(request).await?);
624+
}
625+
Ok(outcomes)
626+
}
627+
547628
/// List channels.
548629
pub async fn list_channels(&self, include_archived: bool) -> Result<Vec<Channel>> {
549630
let query = if include_archived {

0 commit comments

Comments
 (0)