Skip to main content

basis/
agents.rs

1//! Who `@<name>` is, when it is a remote agent (ADR-0037) — the contract
2//! between `spawn`'s grammar and whoever answers it.
3//!
4//! A workspace open answers `@<name>` through one or more resolvers, asked in
5//! order: the one a host installs
6//! ([`WorkspaceBuilder::with_agent_resolver`](crate::WorkspaceBuilder::with_agent_resolver)),
7//! then whatever the build adds (the `a2a` feature's configured set, when it
8//! is on). A resolver that does not know
9//! a name says [`Unresolved::Unknown`] and the next one is asked; the first
10//! other answer stands.
11//!
12//! What crosses the seam is small on purpose: a name in, a [`Peer`] out —
13//! something a task can be handed to and that answers (ADR-0038). A2A is one
14//! peer, behind its feature; naming needs none. Connecting, caching, credentials and retries are the resolver's.
15//! basis keeps everything around it — the conversation with each agent, the
16//! hop metadata, the exchange, and the one `spawn` door — so a host never
17//! reimplements ADR-0029 §5 to answer a name its own way.
18//!
19//! `spawn` asks twice. Its authorization preview runs before the approver and
20//! cannot wait on the network, so [`AgentResolver::route`] answers from what
21//! the resolver already knows. Its execution can wait, so
22//! [`AgentResolver::resolve`] does the work.
23
24mod peer;
25pub(crate) mod remote;
26
27use std::{sync::Arc, time::Duration};
28
29use async_trait::async_trait;
30
31pub use peer::{
32    Attachment, ExchangeBounds, Failure, Message, Outcome, Peer, PeerError, Request, Started,
33    StillWorking,
34};
35
36/// What a resolver calls after its set changes — an agent added, removed or
37/// reached — so the description `spawn` is registered with is captured again
38/// (ADR-0031 §3). Shared, because every resolver of one open is handed the
39/// same hook. Never called with a resolver's own lock held, because what it
40/// does reads the resolver.
41pub type OnChange = Arc<dyn Fn() + Send + Sync>;
42
43/// Who `@<name>` is, for one workspace open.
44///
45/// One required method. The rest default to the least a resolver can be: one
46/// that cannot say anything without the network, teaches nothing, and never
47/// changes. A resolver over a set it can enumerate — a file, a fixed list —
48/// overrides all three.
49#[async_trait]
50pub trait AgentResolver: Send + Sync {
51    /// The client `@name` is handed to, or why there is none.
52    ///
53    /// Connecting, caching, credentials and retries are the resolver's; basis
54    /// holds the peer it is given for the whole exchange, so a resolver that
55    /// hands out a new peer changes what the *next* `@name` reaches, never an
56    /// exchange in flight (ADR-0031 §6).
57    async fn resolve(&self, name: &str) -> Result<Resolved, Unresolved>;
58
59    /// Where `@name` stands without waiting on anything — the authorization
60    /// preview's question, asked before the approver.
61    ///
62    /// The default is [`Route::Maybe`]: the preview claims the delegation may
63    /// leave the process, and [`resolve`](Self::resolve) decides.
64    fn route(&self, _name: &str) -> Route {
65        Route::Maybe
66    }
67
68    /// What `spawn` teaches the model about this resolver's names, read each
69    /// time mentra captures the description. The default teaches nothing, so
70    /// an agent is never taught a prefix that leads nowhere.
71    fn taught(&self) -> Taught {
72        Taught::default()
73    }
74
75    /// Given once, when the resolver is installed on an open: what to call
76    /// after the set changes. The default keeps nothing, for a set that never
77    /// changes.
78    fn on_change(&self, _hook: OnChange) {}
79}
80
81/// Where `@<name>` stands, decided without waiting on anything.
82#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum Route {
85    /// A remote agent this resolver knows, reachable or due a retry: the
86    /// call may go ahead, and resolving it is the first thing it does.
87    Known,
88    /// Not this resolver's; the next one is asked.
89    Unknown,
90    /// Cannot say without the network.
91    Maybe,
92    /// Known, unreachable, and tried too recently to try again for this long
93    /// (ADR-0031 §2).
94    Backoff(Duration),
95    /// Known, and refused for this reason — a complete sentence in the
96    /// resolver's words, which `spawn` follows with its own guidance.
97    Refused(String),
98}
99
100/// A remote agent, resolved: the peer to talk to, and who it is.
101#[derive(Clone)]
102#[non_exhaustive]
103pub struct Resolved {
104    /// What the task is handed to.
105    pub peer: Arc<dyn Peer>,
106    /// The permanent identity the conversation is kept under (ADR-0037 §6).
107    /// The canonical name when the resolver has nothing more permanent.
108    pub id: String,
109    /// The canonical name: `ns/name` for a short handle the resolver
110    /// expanded.
111    pub name: String,
112    /// Where the peer reaches the agent, as a string stable across
113    /// processes, so a task outstanding elsewhere reads as moved
114    /// (ADR-0031 §5).
115    pub endpoint: String,
116}
117
118impl std::fmt::Debug for Resolved {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("Resolved")
121            .field("id", &self.id)
122            .field("name", &self.name)
123            .field("endpoint", &self.endpoint)
124            .finish_non_exhaustive()
125    }
126}
127
128impl Resolved {
129    /// A resolved agent whose id is its name.
130    pub fn new(peer: Arc<dyn Peer>, name: impl Into<String>, endpoint: impl Into<String>) -> Self {
131        let name = name.into();
132        Self {
133            peer,
134            id: name.clone(),
135            name,
136            endpoint: endpoint.into(),
137        }
138    }
139
140    /// Keeps the conversation under `id` rather than the name.
141    #[must_use]
142    pub fn with_id(self, id: impl Into<String>) -> Self {
143        Self {
144            id: id.into(),
145            ..self
146        }
147    }
148
149    /// Records `endpoint` with an outstanding task instead.
150    #[must_use]
151    pub fn with_endpoint(self, endpoint: impl Into<String>) -> Self {
152        Self {
153            endpoint: endpoint.into(),
154            ..self
155        }
156    }
157}
158
159/// Why a resolver has no client to hand back.
160#[derive(Debug, Clone, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum Unresolved {
163    /// Not this resolver's. The next one is asked; when none knows the name,
164    /// `spawn` says so.
165    Unknown,
166    /// Known, unreachable, and tried too recently to try again for this long.
167    Backoff(Duration),
168    /// Known, tried just now, and not reached; tried again after `retry_in`
169    /// when the resolver backs off.
170    Unreachable {
171        /// What went wrong, in words safe to show the model: no server text,
172        /// no header value.
173        error: String,
174        retry_in: Option<Duration>,
175    },
176    /// Known, and refused for this reason — a complete sentence in the
177    /// resolver's words, which `spawn` follows with its own guidance.
178    Refused(String),
179}
180
181/// What a resolver teaches: the names it can list, and a sentence for the
182/// ones it cannot.
183#[derive(Debug, Clone, Default, PartialEq, Eq)]
184#[non_exhaustive]
185pub struct Taught {
186    /// Agents to list, in the resolver's order.
187    pub agents: Vec<TaughtAgent>,
188    /// One sentence for names the list cannot enumerate, as "`@<team>/<agent>`
189    /// reaches any agent in the company registry".
190    pub note: Option<String>,
191}
192
193impl Taught {
194    /// Teaches `agent`, after those already taught.
195    #[must_use]
196    pub fn with_agent(mut self, agent: TaughtAgent) -> Self {
197        self.agents.push(agent);
198        self
199    }
200
201    /// Teaches `note` about names the list does not hold.
202    #[must_use]
203    pub fn with_note(self, note: impl Into<String>) -> Self {
204        Self {
205            note: Some(note.into()),
206            ..self
207        }
208    }
209}
210
211/// One remote agent as the model is taught it.
212#[derive(Debug, Clone, PartialEq, Eq)]
213#[non_exhaustive]
214pub struct TaughtAgent {
215    /// The name the model writes after `@`.
216    pub name: String,
217    /// What its card says, in the words its own card chose — or `None` when
218    /// it is configured but could not be reached, so the model is told it
219    /// may still ask, and asking retries it.
220    pub summary: Option<String>,
221}
222
223impl TaughtAgent {
224    /// An agent taught by name alone, as one that could not be reached.
225    pub fn new(name: impl Into<String>) -> Self {
226        Self {
227            name: name.into(),
228            summary: None,
229        }
230    }
231
232    /// Teaches what the agent's card says.
233    #[must_use]
234    pub fn with_summary(self, summary: impl Into<String>) -> Self {
235        Self {
236            summary: Some(summary.into()),
237            ..self
238        }
239    }
240}