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