aion_server/assistant/descriptor.rs
1//! What the server says about the assistant it carries.
2//!
3//! The description is DERIVED on every read, and it is a description of a
4//! SESSION surface: which harnesses this build ships and whether this machine
5//! can run each one, what tools a session's agent is handed, whether sessions
6//! can be opened at all, and what the reading caller is authorized to do.
7//! Nothing is remembered from boot — the harness availability is measured while
8//! the description is built, and the grant rows are read off this request's own
9//! identity — so a description cannot go stale behind an operator installing a
10//! harness or a grant changing between restarts.
11//!
12//! There is no workflow here. The assistant is a server-owned session
13//! (`super::sessions`), not a document the engine runs, so this carries no
14//! workflow type, no task queue, no content hash and no catalog residency.
15
16use aion_integration_acp::catalogue;
17use serde::Serialize;
18
19use super::sessions::{AssistantSessionError, AssistantSessions};
20use crate::namespace::CallerIdentity;
21use crate::namespace::grants::GRANT_WORDS;
22
23/// One harness this server can open an assistant session on.
24///
25/// The list IS the catalogue this build ships
26/// ([`aion_integration_acp::catalogue`]) — an operator never types a command —
27/// and each entry carries the two facts a picker needs beside the name: whether
28/// this machine can actually run it, and what to install when it cannot.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct AssistantHarnessDescriptor {
31 /// The catalogue id, which a create request selects by.
32 pub name: String,
33 /// The adapter that runs it.
34 pub kind: String,
35 /// The declared login account names, in declaration order. Empty when the
36 /// harness declares none, which is a complete answer: a session on it then
37 /// names no account.
38 pub accounts: Vec<String>,
39 /// Whether [`Self::launch`]'s program resolves on THIS server's `PATH`,
40 /// measured while this description was being built.
41 ///
42 /// Never cached: an operator who installs Node.js and reloads the console
43 /// must see the entry come alive without restarting the server, and a server
44 /// that remembered a boot-time reading would be reporting a machine as it
45 /// was rather than as it is.
46 pub available: bool,
47 /// The catalogue's own sentence naming what to install, or `None` when the
48 /// harness is available. Present exactly when [`Self::available`] is false,
49 /// so a client renders a hint or nothing and never both.
50 pub install_hint: Option<String>,
51 /// The exact line this server would run for it. DISPLAY only: it is shown
52 /// so an operator can see what a session starts, and it is not something any
53 /// request may set.
54 pub launch: String,
55}
56
57/// The tool wiring every session on this server is handed.
58///
59/// NAMES only, never a command line or a URL: what tools an agent is given is
60/// something an operator must be able to see from the console, and how they are
61/// reached is a spawn detail that would leak a local path or an internal
62/// endpoint to every reader of this description.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct AssistantToolsDescriptor {
65 /// Whether this server's own general MCP endpoint is handed to the harness
66 /// — the aion tools an agent drives workflows with.
67 pub aion: bool,
68 /// The assistant's OWN tool server: the second MCP endpoint every session's
69 /// agent is handed, separate from the general one.
70 pub assistant: AssistantOwnToolsDescriptor,
71}
72
73/// The assistant-only MCP server handed to every session's agent.
74///
75/// Published so an operator can see, without reading the source, that a session
76/// hands its agent a tool for reading the console screen — what it is called,
77/// where it is served, and what credential it takes.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct AssistantOwnToolsDescriptor {
80 /// The name the agent shows for it.
81 pub server: String,
82 /// The route it is served on.
83 pub route: String,
84 /// Every tool in its catalogue. Exactly one, and it is not in the general
85 /// catalogue — a general caller asking for it is refused by name.
86 pub tools: Vec<String>,
87 /// Whether it is actually handed over on this server. `false` when no
88 /// dialable address can be stated (a configured port of zero).
89 pub handed_over: bool,
90 /// Why it is not handed over, or `None` when it is.
91 pub unavailable_reason: Option<String>,
92 /// The credential it accepts.
93 pub token: AssistantSessionTokenDescriptor,
94}
95
96/// The credential the assistant tool route accepts.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98pub struct AssistantSessionTokenDescriptor {
99 /// The wire word for the kind, for a client that branches on it.
100 pub kind: String,
101 /// Who mints it: this server, never a caller.
102 pub minted_by: String,
103 /// What it authorizes: one session, never a namespace or a person.
104 pub scope: String,
105 /// The one sentence describing it, from
106 /// [`crate::assistant::mcp::SESSION_TOKEN_DESCRIPTION`] — the SAME sentence
107 /// `docs/operations/API.md` quotes, so there is one description of one
108 /// credential rather than two that could drift.
109 pub description: String,
110}
111
112impl AssistantSessionTokenDescriptor {
113 /// The description of the credential this binary mints.
114 #[must_use]
115 pub fn current() -> Self {
116 Self {
117 kind: crate::assistant::mcp::SESSION_TOKEN_KIND.to_owned(),
118 minted_by: TOKEN_MINTED_BY.to_owned(),
119 scope: TOKEN_SCOPE.to_owned(),
120 description: crate::assistant::mcp::SESSION_TOKEN_DESCRIPTION.to_owned(),
121 }
122 }
123}
124
125/// Who mints the session bearer. The server, always: a caller cannot present
126/// one it made, because verification is against a digest only the server wrote.
127const TOKEN_MINTED_BY: &str = "server";
128/// What the session bearer authorizes.
129const TOKEN_SCOPE: &str = "session";
130/// Why the assistant tool server is not handed over when it is not.
131const NO_DIALABLE_ADDRESS: &str = "this server cannot state an address an agent could dial back on (`server.listen_address` \
132 names port 0, whose real port is only known after bind), so no MCP server of ours is handed \
133 to a session's agent and it cannot read what is on the operator's screen";
134
135/// One grant word this deployment defines, and whether the caller reading this
136/// description holds it.
137///
138/// Built by walking [`GRANT_WORDS`], never a hand-written list: a word that
139/// existed in the grammar and not here would be grantable and undiscoverable.
140/// The `description` is the grammar row's own sentence, so the console states
141/// what a word authorises rather than inventing a meaning for it.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct AssistantGrantDescriptor {
144 /// The stable word an operator and an audit line spell.
145 pub name: String,
146 /// Whether this caller holds it.
147 pub held: bool,
148 /// One sentence naming what the word authorises, from the grammar row.
149 pub description: String,
150}
151
152/// The adapter kind every resolved harness runs.
153///
154/// [`crate::config::ResolvedAssistantHarness`] carries no kind because there is
155/// nothing per-harness left to remember: `config/assistant_resolve.rs` refuses
156/// every value but this one at load, so a RESOLVED harness is an ACP harness by
157/// construction. It is published anyway — a client must not have to know that
158/// rule to read this description — and pinned against the resolver itself by
159/// `the_published_harness_kind_is_the_one_resolution_accepts` in
160/// `api/http/assistant_sessions_tests.rs`, so the two cannot drift.
161const HARNESS_KIND: &str = "acp";
162
163/// The name the agent shows for the assistant's own tool server.
164///
165/// Pinned against `launch.rs`'s own constant by
166/// `the_published_assistant_server_name_is_the_one_a_spawn_hands_over`, so the
167/// description and the spawn cannot name two different servers.
168const ASSISTANT_TOOL_SERVER_NAME: &str = "assistant";
169
170/// The served description of the built-in assistant.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172pub struct AssistantDescriptor {
173 /// Every harness this server can open an assistant session on — the
174 /// build's catalogue, in catalogue order, each with its availability on this
175 /// machine.
176 pub harnesses: Vec<AssistantHarnessDescriptor>,
177 /// The harness THIS caller last opened a session on, or `None` before they
178 /// have opened one.
179 ///
180 /// A memory, not a policy: it is written by `createSession` from what the
181 /// operator actually did, and it is caller-scoped, so one operator's habit
182 /// never preselects another's console. `None` is a complete answer — a
183 /// client preselects the first available entry rather than the server
184 /// inventing a choice nobody made.
185 pub default_harness: Option<String>,
186 /// The tool wiring every session on this server is handed.
187 pub tools: AssistantToolsDescriptor,
188 /// Whether this server can open an assistant session at all.
189 pub sessions_enabled: bool,
190 /// Why it cannot, in words an operator can act on, or `None` when it can.
191 ///
192 /// Reserved for a refusal the PRODUCT can name — today, a durable store this
193 /// server could not read at start-up. It is never "not configured": a stock
194 /// server with no `[assistant]` section serves the assistant, and a harness
195 /// this machine cannot run is said per entry, with its install hint, rather
196 /// than as one sentence about the whole surface.
197 pub sessions_disabled_reason: Option<String>,
198 /// Every grant word this deployment defines, and whether the caller reading
199 /// this description holds it.
200 pub grants: Vec<AssistantGrantDescriptor>,
201}
202
203/// Describes this build's harness catalogue against `sessions`' configuration
204/// and availability, and what `caller` is authorized to do.
205///
206/// Everything is DERIVED at request time and nothing is remembered from boot —
207/// including the grant rows, which are read off this request's own resolved
208/// identity, so one description can never report another caller's
209/// authorization.
210///
211/// # Errors
212///
213/// Whatever the store reports while reading this caller's last harness pick.
214/// The pick is a stored fact about the caller, so it is read rather than
215/// remembered, and a store that cannot answer is reported rather than being
216/// rendered as "no pick" — which is a different, and wrong, thing to tell a
217/// console.
218pub async fn describe(
219 sessions: &AssistantSessions,
220 caller: &CallerIdentity,
221) -> Result<AssistantDescriptor, AssistantSessionError> {
222 let config = sessions.config();
223 let availability = sessions.availability();
224 let default_harness = sessions.last_harness_pick(caller.subject()).await?;
225 Ok(AssistantDescriptor {
226 harnesses: catalogue::CATALOGUE
227 .iter()
228 .map(|entry| AssistantHarnessDescriptor {
229 name: entry.id.to_owned(),
230 kind: HARNESS_KIND.to_owned(),
231 accounts: config.account_names(entry.id),
232 // MEASURED here, on every read. The hint is carried exactly when
233 // it is needed, so a client cannot render "install Node.js"
234 // beside a harness that is already running.
235 available: entry.available(),
236 install_hint: (!entry.available()).then(|| entry.install_hint.to_owned()),
237 launch: entry.launch(),
238 })
239 .collect(),
240 default_harness,
241 tools: AssistantToolsDescriptor {
242 aion: sessions.hands_over_general_mcp(),
243 assistant: AssistantOwnToolsDescriptor {
244 server: ASSISTANT_TOOL_SERVER_NAME.to_owned(),
245 route: crate::assistant::mcp::ASSISTANT_MCP_PATH.to_owned(),
246 tools: crate::assistant::mcp::SESSION_TOOL_NAMES
247 .iter()
248 .map(|name| (*name).to_owned())
249 .collect(),
250 handed_over: sessions.hands_over_assistant_tools(),
251 unavailable_reason: (!sessions.hands_over_assistant_tools())
252 .then(|| NO_DIALABLE_ADDRESS.to_owned()),
253 token: AssistantSessionTokenDescriptor::current(),
254 },
255 },
256 sessions_enabled: availability.is_available(),
257 sessions_disabled_reason: availability.reason().map(ToOwned::to_owned),
258 grants: GRANT_WORDS
259 .iter()
260 .map(|grant| AssistantGrantDescriptor {
261 name: grant.word().to_owned(),
262 held: grant.granted_for(caller),
263 description: grant.description().to_owned(),
264 })
265 .collect(),
266 })
267}