1use std::fmt;
8use std::str::FromStr;
9use std::time::Duration;
10
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13
14use crate::id::{ConversationKey, UserRef};
15use crate::{BoxError, async_trait};
16
17pub const PEER_PLATFORM: &str = "peer";
19
20pub const PEER_CANCEL_PREFIX: &str = "peer-cancel:";
23
24#[derive(Debug, thiserror::Error)]
26#[non_exhaustive]
27pub enum PeerError {
28 #[error("no member of the team can take this: {0}")]
30 NoRoute(String),
31 #[error("refused: {0}")]
33 Refused(String),
34 #[error("unreachable: {0}")]
36 Unreachable(String),
37 #[error("no such task")]
39 UnknownTask,
40 #[error("peer error: {0}")]
42 Other(#[source] BoxError),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct DelegationContext {
49 pub origin: Origin,
51 pub chain: Vec<String>,
53 pub deadline: Timestamp,
55 pub budget: Budget,
57 pub trace: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Origin {
64 pub bot: String,
66 pub conversation: String,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub person: Option<String>,
71 pub name: String,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Budget {
78 pub turns: u32,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub tokens: Option<u64>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
87pub enum PeerTarget {
88 Member(String),
90 Role(String),
92 Skill(String),
94}
95
96impl FromStr for PeerTarget {
97 type Err = PeerError;
98
99 fn from_str(s: &str) -> Result<Self, Self::Err> {
100 let s = s.trim();
101 let target = if let Some(role) = s.strip_prefix("role:") {
102 Self::Role(role.to_string())
103 } else if let Some(skill) = s.strip_prefix("skill:") {
104 Self::Skill(skill.to_string())
105 } else {
106 Self::Member(s.to_string())
107 };
108 match &target {
109 Self::Member(n) | Self::Role(n) | Self::Skill(n) if n.is_empty() => {
110 Err(PeerError::NoRoute("an empty target".into()))
111 }
112 _ => Ok(target),
113 }
114 }
115}
116
117impl fmt::Display for PeerTarget {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Self::Member(n) => f.write_str(n),
121 Self::Role(n) => write!(f, "role:{n}"),
122 Self::Skill(n) => write!(f, "skill:{n}"),
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Delegation {
130 pub team: Option<String>,
132 pub to: PeerTarget,
134 pub task: String,
136 pub context: DelegationContext,
138 pub wait: Duration,
140 pub message_id: String,
142 pub requester: Option<UserRef>,
144 pub direct: bool,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
150#[serde(rename_all = "kebab-case")]
151pub enum PeerTaskState {
152 Submitted,
154 Working,
156 InputRequired,
158 Completed,
160 Failed,
162 Canceled,
164 Rejected,
166}
167
168impl PeerTaskState {
169 pub fn is_final(self) -> bool {
171 matches!(
172 self,
173 Self::Completed | Self::Failed | Self::Canceled | Self::Rejected
174 )
175 }
176
177 pub fn as_str(self) -> &'static str {
179 match self {
180 Self::Submitted => "submitted",
181 Self::Working => "working",
182 Self::InputRequired => "input-required",
183 Self::Completed => "completed",
184 Self::Failed => "failed",
185 Self::Canceled => "canceled",
186 Self::Rejected => "rejected",
187 }
188 }
189}
190
191impl FromStr for PeerTaskState {
192 type Err = PeerError;
193
194 fn from_str(s: &str) -> Result<Self, Self::Err> {
195 Ok(match s {
196 "submitted" => Self::Submitted,
197 "working" => Self::Working,
198 "input-required" => Self::InputRequired,
199 "completed" => Self::Completed,
200 "failed" => Self::Failed,
201 "canceled" => Self::Canceled,
202 "rejected" => Self::Rejected,
203 other => return Err(PeerError::Refused(format!("unknown task state {other}"))),
204 })
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct PeerTask {
211 pub team: String,
213 pub peer: String,
215 pub id: String,
217 pub state: PeerTaskState,
219 pub text: Option<String>,
221 pub prompt: Option<PeerPrompt>,
223 pub tokens: Option<u64>,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct PeerPrompt {
230 pub id: String,
232 pub buttons: Vec<(String, String)>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
238pub struct PeerInfo {
239 pub team: String,
241 pub name: String,
243 pub roles: Vec<String>,
245 pub skills: Vec<String>,
247 pub description: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct PeerCaller {
254 pub team: String,
256 pub member: String,
258 pub roles: Vec<String>,
260 pub context: DelegationContext,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct PeerTaskRecord {
267 pub id: String,
269 pub inbound: bool,
271 pub team: String,
273 pub peer: String,
275 pub context_id: String,
277 pub message_id: String,
279 pub state: PeerTaskState,
281 pub conversation: Option<ConversationKey>,
283 pub trace: String,
285 pub text: Option<String>,
287 pub requester: Option<UserRef>,
289 pub direct: bool,
291 pub tokens: Option<u64>,
293 pub created_at: Timestamp,
295 pub updated_at: Timestamp,
297}
298
299#[async_trait]
301pub trait Peers: Send + Sync {
302 async fn directory(&self) -> Result<Vec<PeerInfo>, PeerError>;
304
305 async fn delegate(&self, delegation: Delegation) -> Result<PeerTask, PeerError>;
307
308 async fn status(&self, team: &str, peer: &str, task: &str) -> Result<PeerTask, PeerError>;
310
311 async fn cancel(&self, team: &str, peer: &str, task: &str) -> Result<PeerTask, PeerError>;
313
314 async fn report_usage(&self, conversation: &ConversationKey, task: Option<&str>, tokens: u64);
317
318 fn progress(&self, conversation: &ConversationKey, task: Option<&str>, text: &str);
321
322 async fn fail(&self, conversation: &ConversationKey, task: Option<&str>, reason: &str);
325
326 async fn answer(
328 &self,
329 task: &PeerTask,
330 prompt: &str,
331 button: &str,
332 ) -> Result<PeerTask, PeerError>;
333
334 async fn caller(&self, conversation: &ConversationKey) -> Option<PeerCaller>;
336
337 fn team_person(&self, account: &str) -> Option<String>;
339
340 fn member_name(&self, team: &str) -> Option<String>;
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn targets_parse() {
350 assert_eq!(
351 "ops".parse::<PeerTarget>().unwrap(),
352 PeerTarget::Member("ops".into())
353 );
354 assert_eq!(
355 "role:leader".parse::<PeerTarget>().unwrap(),
356 PeerTarget::Role("leader".into())
357 );
358 assert_eq!(
359 "skill:web".parse::<PeerTarget>().unwrap().to_string(),
360 "skill:web"
361 );
362 assert!("role:".parse::<PeerTarget>().is_err());
363 assert!("".parse::<PeerTarget>().is_err());
364 }
365
366 #[test]
367 fn states_round_trip() {
368 for state in [
369 PeerTaskState::Submitted,
370 PeerTaskState::Working,
371 PeerTaskState::InputRequired,
372 PeerTaskState::Completed,
373 PeerTaskState::Failed,
374 PeerTaskState::Canceled,
375 PeerTaskState::Rejected,
376 ] {
377 assert_eq!(state.as_str().parse::<PeerTaskState>().unwrap(), state);
378 }
379 assert!(PeerTaskState::Completed.is_final());
380 assert!(!PeerTaskState::InputRequired.is_final());
381 }
382}