Skip to main content

okbot_peer/
outbound.rs

1//! Delegating work to other members: the [`Peers`] port.
2
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use okbot_core::{
7    ConversationKey, Delegation, PeerError, PeerInfo, PeerPrompt, PeerTask, PeerTaskRecord,
8    PeerTaskState, Peers, async_trait,
9};
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12
13use crate::a2a::{self, Part};
14use crate::node::{Inner, Team, lock};
15
16/// Polling a task that runs: first and longest interval.
17const POLL_FIRST: Duration = Duration::from_millis(500);
18const POLL_MAX: Duration = Duration::from_secs(3);
19
20/// A bot's peer side: [`Peers`] for delegating, a [`Channel`](okbot_core::Channel) per team for
21/// tasks it receives.
22#[derive(Clone)]
23pub struct Node {
24    pub(crate) inner: Arc<Inner>,
25}
26
27impl std::fmt::Debug for Node {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("Node")
30            .field("teams", &self.inner.teams.keys().collect::<Vec<_>>())
31            .finish_non_exhaustive()
32    }
33}
34
35impl Node {
36    /// A node for `config`; file manifests are read now.
37    pub fn new(
38        config: crate::NodeConfig,
39        store: Arc<dyn okbot_core::ConversationStore>,
40        clock: Arc<dyn okbot_core::Clock>,
41    ) -> Result<Self, crate::Error> {
42        Ok(Self {
43            inner: Arc::new(Inner::new(config, store, clock)?),
44        })
45    }
46
47    /// Fetches the manifests of URL teams, marks tasks left by a stop as failed, and returns
48    /// problems worth a warning (this bot missing from a team, a manifest that cannot be read).
49    pub async fn start(&self) -> Vec<String> {
50        let mut problems = Vec::new();
51        for team in self.inner.teams.values() {
52            if team.signed()
53                && let Err(error) = self.inner.refresh(team).await
54            {
55                problems.push(format!("team `{}`: {error}", team.config.id));
56            }
57        }
58        problems.extend(self.inner.check_membership());
59        match self
60            .inner
61            .store
62            .interrupt_peer_tasks(self.inner.now())
63            .await
64        {
65            Ok(0) => {}
66            Ok(n) => tracing::warn!(
67                tasks = n,
68                "peer tasks interrupted by the last stop were marked failed"
69            ),
70            Err(error) => tracing::warn!(%error, "cannot mark interrupted peer tasks"),
71        }
72        problems
73    }
74
75    /// One `peer:<team>` channel per team: it serves the team's endpoint (tasks when the team's
76    /// `accept` allows them, relaying, refusals that say why) and the bot's socket.
77    pub fn channels(&self) -> Vec<Arc<dyn okbot_core::Channel>> {
78        self.inner
79            .teams
80            .values()
81            .map(|t| {
82                Arc::new(crate::channel::PeerChannel::new(
83                    self.inner.clone(),
84                    &t.config.id,
85                )) as Arc<dyn okbot_core::Channel>
86            })
87            .collect()
88    }
89
90    /// Checks each team without changing anything: its manifest (fetched for URL teams), this
91    /// bot's membership, and whether the members it may call are reachable. One line per finding,
92    /// with `true` when it is fine.
93    pub async fn check(&self) -> Vec<(String, bool, String)> {
94        let mut out = Vec::new();
95        for team in self.inner.teams.values() {
96            let label = format!("team {}", team.config.id);
97            if let Err(error) = self.inner.refresh(team).await {
98                out.push((label, false, error.to_string()));
99                continue;
100            }
101            let manifest = team.manifest();
102            let problems: Vec<String> = self
103                .inner
104                .check_membership()
105                .into_iter()
106                .filter(|p| p.starts_with(&format!("team `{}`", team.config.id)))
107                .collect();
108            if !problems.is_empty() {
109                out.push((label, false, problems.join("; ")));
110                continue;
111            }
112            let callees = manifest.callees(&team.config.member);
113            out.push((
114                label.clone(),
115                true,
116                format!(
117                    "member `{}`, version {}{}; may call {}; {}",
118                    team.config.member,
119                    manifest.team.version,
120                    manifest
121                        .team
122                        .expires
123                        .map(|e| format!(" (expires {e})"))
124                        .unwrap_or_default(),
125                    if callees.is_empty() {
126                        "nobody".into()
127                    } else {
128                        callees.join(", ")
129                    },
130                    if team.config.accept {
131                        "accepts tasks"
132                    } else {
133                        "accepts no tasks"
134                    },
135                ),
136            ));
137            for name in callees {
138                let member = &manifest.members[name];
139                let (ok, detail) = match (&member.dir, &member.url, &member.relay) {
140                    (Some(dir), _, _) => {
141                        let socket = crate::node::socket_path(&self.inner.sockets, dir);
142                        if socket.exists() {
143                            (true, format!("socket {}", socket.display()))
144                        } else {
145                            (
146                                false,
147                                format!("{} is missing: is that bot running?", socket.display()),
148                            )
149                        }
150                    }
151                    (_, Some(url), _) => match self.inner.http.get(url).send().await {
152                        // Without a token the endpoint answers 401: it is there. An agent
153                        // outside okbot may answer anything short of a server error.
154                        Ok(r) if r.status().as_u16() == 401 => (true, url.clone()),
155                        Ok(r)
156                            if team.config.id == crate::external::TEAM
157                                && r.status().as_u16() < 500 =>
158                        {
159                            (true, url.clone())
160                        }
161                        Ok(r) => (
162                            false,
163                            format!("{url} answered HTTP {}", r.status().as_u16()),
164                        ),
165                        Err(e) => (false, format!("{url}: {}", e.without_url())),
166                    },
167                    (_, _, Some(relay)) => (true, format!("through `{relay}`")),
168                    _ => (false, "no way to reach it".into()),
169                };
170                out.push((format!("{label} → {name}"), ok, detail));
171            }
172        }
173        out
174    }
175
176    /// The team ids.
177    pub fn teams(&self) -> Vec<String> {
178        self.inner.teams.keys().cloned().collect()
179    }
180}
181
182/// A task from a `GetTask`/`CancelTask` result or a send result, in either version.
183pub(crate) fn task_from(team: &str, peer: &str, value: &Value) -> Result<PeerTask, PeerError> {
184    let task = a2a::Task::from_wire(a2a::unwrap_send_result(value))
185        .ok_or_else(|| PeerError::Refused(format!("`{peer}` sent an invalid task")))?;
186    let state = task.state;
187    let message = task.message.as_ref();
188    let text = message.map(a2a::Message::text).filter(|t| !t.is_empty());
189    let prompt = message
190        .and_then(a2a::Message::data)
191        .filter(|_| state == PeerTaskState::InputRequired)
192        .and_then(|data| {
193            let id = data.get("prompt")?.as_str()?.to_string();
194            let buttons = data
195                .get("buttons")?
196                .as_array()?
197                .iter()
198                .filter_map(|b| {
199                    Some((
200                        b.get("id")?.as_str()?.to_string(),
201                        b.get("label")?.as_str()?.to_string(),
202                    ))
203                })
204                .collect();
205            Some(PeerPrompt { id, buttons })
206        });
207    let tokens = message
208        .and_then(|m| m.metadata.as_ref())
209        .and_then(|m| m.pointer(&format!("/{}/tokens", a2a::USAGE_KEY.replace('/', "~1"))))
210        .and_then(Value::as_u64);
211    Ok(PeerTask {
212        team: team.to_string(),
213        peer: peer.to_string(),
214        id: task.id,
215        state,
216        text,
217        prompt,
218        tokens,
219    })
220}
221
222impl Node {
223    fn team(&self, id: &str) -> Result<&Team, PeerError> {
224        self.inner
225            .teams
226            .get(id)
227            .ok_or_else(|| PeerError::NoRoute(format!("no team `{id}`")))
228    }
229
230    async fn record(&self, task: &PeerTask, delegation: Option<(&Delegation, &str)>) {
231        let now = self.inner.now();
232        let existing = self
233            .inner
234            .store
235            .peer_task(false, &task.id)
236            .await
237            .ok()
238            .flatten();
239        let record = match (existing, delegation) {
240            (Some(old), _) => PeerTaskRecord {
241                state: task.state,
242                text: task.text.clone(),
243                tokens: task.tokens.or(old.tokens),
244                updated_at: now,
245                ..old
246            },
247            (None, Some((d, context_id))) => PeerTaskRecord {
248                id: task.id.clone(),
249                inbound: false,
250                team: task.team.clone(),
251                peer: task.peer.clone(),
252                context_id: context_id.to_string(),
253                message_id: d.message_id.clone(),
254                state: task.state,
255                conversation: d
256                    .context
257                    .origin
258                    .conversation
259                    .parse::<ConversationKey>()
260                    .ok(),
261                trace: d.context.trace.clone(),
262                text: task.text.clone(),
263                requester: d.requester.clone(),
264                direct: d.direct,
265                tokens: task.tokens,
266                created_at: now,
267                updated_at: now,
268            },
269            (None, None) => return,
270        };
271        if let Err(error) = self.inner.store.put_peer_task(record).await {
272            tracing::warn!(%error, "cannot record a delegated task");
273        }
274    }
275
276    async fn get(&self, team: &Team, peer: &str, id: &str) -> Result<PeerTask, PeerError> {
277        let version = self.inner.version_of(team, peer);
278        let request = a2a::Request::new(a2a::method(a2a::Op::Get, version), json!({"id": id}));
279        let value = self.inner.call(team, peer, &request).await?;
280        task_from(&team.config.id, peer, &value)
281    }
282
283    /// Polls until the task ends, asks for input, or `until`.
284    async fn wait(&self, team: &Team, mut task: PeerTask, until: Instant) -> PeerTask {
285        let mut every = POLL_FIRST;
286        while !task.state.is_final() && task.state != PeerTaskState::InputRequired {
287            let left = until.saturating_duration_since(Instant::now());
288            if left.is_zero() {
289                break;
290            }
291            tokio::time::sleep(every.min(left)).await;
292            every = (every * 2).min(POLL_MAX);
293            match self.get(team, &task.peer, &task.id).await {
294                Ok(next) => task = next,
295                Err(error) => {
296                    tracing::debug!(%error, "polling a delegated task failed");
297                }
298            }
299        }
300        task
301    }
302}
303
304#[async_trait]
305impl Peers for Node {
306    async fn directory(&self) -> Result<Vec<PeerInfo>, PeerError> {
307        let mut out = Vec::new();
308        for team in self.inner.teams.values() {
309            let manifest = team.manifest();
310            for name in manifest.callees(&team.config.member) {
311                let member = &manifest.members[name];
312                out.push(PeerInfo {
313                    team: team.config.id.clone(),
314                    name: name.to_string(),
315                    roles: member.roles.clone(),
316                    skills: member.skills.clone(),
317                    description: member.description.clone(),
318                });
319            }
320        }
321        Ok(out)
322    }
323
324    async fn delegate(&self, delegation: Delegation) -> Result<PeerTask, PeerError> {
325        let started = Instant::now();
326        let mut candidates = Vec::new();
327        for team in self.inner.teams.values() {
328            if delegation
329                .team
330                .as_ref()
331                .is_some_and(|t| *t != team.config.id)
332            {
333                continue;
334            }
335            let manifest = team.manifest();
336            for member in manifest.route(&team.config.member, &delegation.to) {
337                candidates.push((team, member.to_string()));
338            }
339        }
340        if candidates.is_empty() {
341            return Err(PeerError::NoRoute(delegation.to.to_string()));
342        }
343        // Members that just failed go last.
344        candidates.sort_by_key(|(team, member)| self.inner.is_down(&team.config.id, member));
345        let mut last = None;
346        for (team, member) in candidates {
347            let mut context = delegation.context.clone();
348            if context.origin.bot.is_empty() {
349                context.origin.bot.clone_from(&team.config.member);
350            }
351            context.chain.push(team.config.member.clone());
352            let own = self.inner.now()
353                + jiff::SignedDuration::try_from(team.config.task_timeout)
354                    .unwrap_or(jiff::SignedDuration::from_secs(600));
355            context.deadline = context.deadline.min(own);
356            // One A2A context per origin conversation and member: the other bot keeps the history
357            // of what this conversation asked it.
358            let context_id = hex::encode(Sha256::digest(format!(
359                "{}\n{}",
360                context.origin.conversation, team.config.member
361            )))[..24]
362                .to_string();
363            let mut message = a2a::Message::user(
364                &delegation.message_id,
365                &context_id,
366                vec![Part::Text(delegation.task.clone())],
367            );
368            if team.config.id == crate::external::TEAM {
369                // An agent outside okbot gets the task only: no person, no chain.
370                message.extensions.clear();
371            } else {
372                message = message.with_context(&context);
373            }
374            let version = self.inner.version_of(team, &member);
375            let request = a2a::Request::new(
376                a2a::method(a2a::Op::Send, version),
377                json!({"message": message.to_wire(version)}),
378            );
379            match self.inner.call(team, &member, &request).await {
380                Ok(value) => {
381                    let task = task_from(&team.config.id, &member, &value)?;
382                    tracing::info!(team = %team.config.id, %member, task = %task.id, trace = %context.trace, "task delegated");
383                    self.record(&task, Some((&delegation, &context_id))).await;
384                    let task = self.wait(team, task, started + delegation.wait).await;
385                    self.record(&task, None).await;
386                    return Ok(task);
387                }
388                Err(error) => {
389                    tracing::warn!(team = %team.config.id, %member, %error, "delegation failed");
390                    last = Some(error);
391                }
392            }
393        }
394        Err(last.unwrap_or_else(|| PeerError::NoRoute(delegation.to.to_string())))
395    }
396
397    async fn status(&self, team: &str, peer: &str, task: &str) -> Result<PeerTask, PeerError> {
398        let team = self.team(team)?;
399        let task = self.get(team, peer, task).await?;
400        self.record(&task, None).await;
401        Ok(task)
402    }
403
404    async fn cancel(&self, team: &str, peer: &str, task: &str) -> Result<PeerTask, PeerError> {
405        let team = self.team(team)?;
406        let version = self.inner.version_of(team, peer);
407        let request = a2a::Request::new(a2a::method(a2a::Op::Cancel, version), json!({"id": task}));
408        let value = self.inner.call(team, peer, &request).await?;
409        let task = task_from(&team.config.id, peer, &value)?;
410        self.record(&task, None).await;
411        Ok(task)
412    }
413
414    async fn report_usage(&self, conversation: &ConversationKey, task: Option<&str>, tokens: u64) {
415        crate::inbound::usage(&self.inner, conversation, task, tokens);
416    }
417
418    fn progress(&self, conversation: &ConversationKey, task: Option<&str>, text: &str) {
419        crate::inbound::progress(&self.inner, conversation, task, text);
420    }
421
422    async fn fail(&self, conversation: &ConversationKey, task: Option<&str>, reason: &str) {
423        crate::inbound::fail(&self.inner, conversation, task, reason).await;
424    }
425
426    async fn answer(
427        &self,
428        task: &PeerTask,
429        prompt: &str,
430        button: &str,
431    ) -> Result<PeerTask, PeerError> {
432        let team = self.team(&task.team)?;
433        let mut message = a2a::Message::user(
434            &hex::encode(rand::random::<[u8; 8]>()),
435            "",
436            vec![Part::Data(json!({"prompt": prompt, "button": button}))],
437        );
438        message.context_id = None;
439        message.task_id = Some(task.id.clone());
440        let version = self.inner.version_of(team, &task.peer);
441        let request = a2a::Request::new(
442            a2a::method(a2a::Op::Send, version),
443            json!({"message": message.to_wire(version)}),
444        );
445        let value = self.inner.call(team, &task.peer, &request).await?;
446        let next = task_from(&team.config.id, &task.peer, &value)?;
447        self.record(&next, None).await;
448        Ok(next)
449    }
450
451    async fn caller(&self, conversation: &ConversationKey) -> Option<okbot_core::PeerCaller> {
452        lock(&self.inner.callers).get(conversation).cloned()
453    }
454
455    fn team_person(&self, account: &str) -> Option<String> {
456        self.inner
457            .teams
458            .values()
459            .find_map(|t| t.manifest().person_of(account).map(str::to_string))
460    }
461
462    fn member_name(&self, team: &str) -> Option<String> {
463        self.inner.teams.get(team).map(|t| t.config.member.clone())
464    }
465}