Skip to main content

okbot_peer/
external.rs

1//! A2A agents and clients that are not okbots (ADR 0014), as the built-in team `external`.
2//!
3//! - Agents (`[[a2a.agent]]`) are members this bot may delegate to: plain A2A over HTTPS with an
4//!   optional bearer token, in the version they speak. They get the task text only: no delegation
5//!   context, no person, no okbot signature.
6//! - Clients (`[[a2a.client]]`) may give this bot tasks at `/a2a/external` with a bearer token.
7//!   Their tasks start a chain of one (they cannot be delegated further), act for the configured
8//!   person if any, and get the client's roles intersected with that person's.
9
10use std::time::Duration;
11
12use okbot_core::{Budget, DelegationContext, Origin, PeerError, Secret};
13use serde_json::Value;
14
15use crate::a2a;
16use crate::manifest::{Link, Manifest, Member};
17
18/// The id of the built-in team.
19pub const TEAM: &str = "external";
20
21/// An A2A agent this bot may delegate to.
22#[derive(Debug, Clone)]
23pub struct ExternalAgent {
24    /// Its name here.
25    pub name: String,
26    /// Its JSON-RPC endpoint.
27    pub url: String,
28    /// The bearer token it expects.
29    pub token: Option<Secret<String>>,
30    /// The A2A version it speaks.
31    pub version: a2a::Version,
32    /// What it does.
33    pub description: String,
34    /// Skills.
35    pub skills: Vec<String>,
36}
37
38/// A program that may give this bot tasks.
39#[derive(Debug, Clone)]
40pub struct ExternalClient {
41    /// Its name.
42    pub name: String,
43    /// The bearer token it presents.
44    pub token: Secret<String>,
45    /// The person (`[people]` id) it acts for.
46    pub person: Option<String>,
47}
48
49/// `[a2a]`.
50#[derive(Debug, Clone, Default)]
51pub struct ExternalConfig {
52    /// Agents.
53    pub agents: Vec<ExternalAgent>,
54    /// Clients.
55    pub clients: Vec<ExternalClient>,
56    /// Tasks at once per client.
57    pub max_concurrent: u32,
58    /// The longest a client's task may take, and a delegation to an agent.
59    pub task_timeout: Duration,
60}
61
62impl ExternalConfig {
63    /// The built-in team's manifest: this bot, the agents it may call, the clients that may call
64    /// it (each client's team role is its name, for `[a2a.client] roles`).
65    pub(crate) fn manifest(&self, me: &str) -> Manifest {
66        let mut m = Manifest::new(TEAM);
67        m.team.version = 1;
68        m.members.insert(me.to_string(), Member::default());
69        for agent in &self.agents {
70            m.members.insert(
71                agent.name.clone(),
72                Member {
73                    url: Some(agent.url.clone()),
74                    skills: agent.skills.clone(),
75                    description: agent.description.clone(),
76                    ..Member::default()
77                },
78            );
79        }
80        for client in &self.clients {
81            m.members.insert(
82                client.name.clone(),
83                Member {
84                    roles: vec![client.name.clone()],
85                    ..Member::default()
86                },
87            );
88        }
89        if !self.clients.is_empty() {
90            m.links.push(Link {
91                from: self.clients.iter().map(|c| c.name.clone()).collect(),
92                to: vec![me.to_string()],
93            });
94        }
95        if !self.agents.is_empty() {
96            m.links.push(Link {
97                from: vec![me.to_string()],
98                to: self.agents.iter().map(|a| a.name.clone()).collect(),
99            });
100        }
101        m
102    }
103
104    /// The client whose token is `bearer` (compared in constant time).
105    pub(crate) fn client(&self, bearer: &str) -> Option<&ExternalClient> {
106        self.clients.iter().find(|c| same(c.token.expose(), bearer))
107    }
108
109    pub(crate) fn agent(&self, name: &str) -> Option<&ExternalAgent> {
110        self.agents.iter().find(|a| a.name == name)
111    }
112}
113
114/// Equal without an early exit on the first differing byte.
115fn same(a: &str, b: &str) -> bool {
116    a.len() == b.len()
117        && a.bytes()
118            .zip(b.bytes())
119            .fold(0u8, |acc, (x, y)| acc | (x ^ y))
120            == 0
121}
122
123/// The context of a client's task: a chain of one, for the client's person, that cannot go
124/// further.
125pub(crate) fn context(
126    client: &ExternalClient,
127    now: jiff::Timestamp,
128    timeout: Duration,
129) -> DelegationContext {
130    DelegationContext {
131        origin: Origin {
132            bot: client.name.clone(),
133            conversation: String::new(),
134            person: client.person.clone(),
135            name: client.name.clone(),
136        },
137        chain: vec![client.name.clone()],
138        deadline: now
139            + jiff::SignedDuration::try_from(timeout)
140                .unwrap_or(jiff::SignedDuration::from_secs(600)),
141        budget: Budget {
142            turns: 1,
143            tokens: None,
144        },
145        trace: hex::encode(rand::random::<[u8; 8]>()),
146    }
147}
148
149/// Sends one JSON-RPC request to an agent.
150pub(crate) async fn call(
151    http: &reqwest::Client,
152    agent: &ExternalAgent,
153    request: &a2a::Request,
154) -> Result<Value, PeerError> {
155    let mut post = http
156        .post(&agent.url)
157        .header("content-type", "application/json")
158        .header(a2a::VERSION_HEADER, agent.version.header())
159        .json(request);
160    if let Some(token) = &agent.token {
161        post = post.bearer_auth(token.expose());
162    }
163    let reply = post.send().await.map_err(|e| {
164        PeerError::Unreachable(if e.is_timeout() {
165            "timed out".into()
166        } else {
167            e.without_url().to_string()
168        })
169    })?;
170    let status = reply.status().as_u16();
171    if !(200..300).contains(&status) {
172        return Err(PeerError::Refused(format!(
173            "`{}` answered HTTP {status}",
174            agent.name
175        )));
176    }
177    reply
178        .json()
179        .await
180        .map_err(|_| PeerError::Refused(format!("`{}` sent invalid JSON", agent.name)))
181}