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}