aion_server/assistant/descriptor.rs
1//! What the server says about the assistant it carries.
2//!
3//! The description is DERIVED on every read: the identity and contract come
4//! from the embedded document's compiled form, and residency comes from the
5//! engine catalog at request time. Nothing is remembered from boot, so a
6//! description cannot go stale behind an operator's `aion deploy` or
7//! `aion route` between restarts.
8
9use aion::Engine;
10use aion_integration_acp::catalogue;
11use serde::Serialize;
12use serde_json::Value;
13
14use super::document::{
15 CONTINUE_END_FIELD, CONTINUE_MESSAGE_FIELD, CONTINUE_SIGNAL, EMBEDDED_ASSISTANT_FILENAME,
16 EmbeddedAssistant, OBJECTIVE_INPUT, REPO_PATH_INPUT, STATUS_QUERY,
17};
18use super::sessions::{AssistantSessionError, AssistantSessions};
19use crate::namespace::CallerIdentity;
20use crate::namespace::grants::GRANT_WORDS;
21
22/// Where the embedded document stands in the engine catalog right now.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "snake_case", tag = "state")]
25pub enum AssistantResidency {
26 /// The embedded version is loaded and holds the route: starting the
27 /// assistant runs this document.
28 Routed,
29 /// The embedded version is loaded but another version holds the route (or
30 /// nothing does). Starting the assistant does not run this document.
31 LoadedNotRouted {
32 /// The routed hash, when some resident version holds the route.
33 routed_hash: Option<String>,
34 },
35 /// The embedded version is not in the catalog at all. `routed_hash` names
36 /// whatever version of the type does hold the route; `None` means the type
37 /// is unstartable on this server.
38 NotLoaded {
39 /// The routed hash, when some other version holds the route.
40 routed_hash: Option<String>,
41 },
42 /// The catalog could not be read, so residency is unknown — never guessed.
43 Unknown {
44 /// Why the catalog could not be read.
45 reason: String,
46 },
47}
48
49/// The names the operator surfaces bind to, verified against the document at
50/// load time. Published so a client drives the session by reading rather than
51/// by restating them.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53pub struct AssistantSessionContract {
54 /// Start input carrying the operator's opening ask.
55 pub objective_input: &'static str,
56 /// Start input carrying the repository path (empty = scratch mode).
57 pub repo_path_input: &'static str,
58 /// The signal a parked session listens on.
59 pub continue_signal: &'static str,
60 /// Continuation field carrying the next prompt.
61 pub message_field: &'static str,
62 /// Continuation field that ends the session.
63 pub end_field: &'static str,
64 /// The read-only status query.
65 pub status_query: &'static str,
66}
67
68impl AssistantSessionContract {
69 /// The contract this binary was built with.
70 #[must_use]
71 pub const fn current() -> Self {
72 Self {
73 objective_input: OBJECTIVE_INPUT,
74 repo_path_input: REPO_PATH_INPUT,
75 continue_signal: CONTINUE_SIGNAL,
76 message_field: CONTINUE_MESSAGE_FIELD,
77 end_field: CONTINUE_END_FIELD,
78 status_query: STATUS_QUERY,
79 }
80 }
81}
82
83/// One declared signal and its payload schema.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct AssistantSignal {
86 /// Signal name.
87 pub name: String,
88 /// Payload schema, draft 2020-12, derived from the document.
89 pub input_schema: Value,
90}
91
92/// One harness this server can open an assistant session on.
93///
94/// The list IS the catalogue this build ships
95/// ([`aion_integration_acp::catalogue`]) — an operator never types a command —
96/// and each entry carries the two facts a picker needs beside the name: whether
97/// this machine can actually run it, and what to install when it cannot.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct AssistantHarnessDescriptor {
100 /// The catalogue id, which a create request selects by.
101 pub name: String,
102 /// The adapter that runs it.
103 pub kind: String,
104 /// The declared login account names, in declaration order. Empty when the
105 /// harness declares none, which is a complete answer: a session on it then
106 /// names no account.
107 pub accounts: Vec<String>,
108 /// Whether [`Self::launch`]'s program resolves on THIS server's `PATH`,
109 /// measured while this description was being built.
110 ///
111 /// Never cached: an operator who installs Node.js and reloads the console
112 /// must see the entry come alive without restarting the server, and a server
113 /// that remembered a boot-time reading would be reporting a machine as it
114 /// was rather than as it is.
115 pub available: bool,
116 /// The catalogue's own sentence naming what to install, or `None` when the
117 /// harness is available. Present exactly when [`Self::available`] is false,
118 /// so a client renders a hint or nothing and never both.
119 pub install_hint: Option<String>,
120 /// The exact line this server would run for it. DISPLAY only: it is shown
121 /// so an operator can see what a session starts, and it is not something any
122 /// request may set.
123 pub launch: String,
124}
125
126/// The tool wiring every session on this server is handed.
127///
128/// NAMES only, never a command line or a URL: what tools an agent is given is
129/// something an operator must be able to see from the console, and how they are
130/// reached is a spawn detail that would leak a local path or an internal
131/// endpoint to every reader of this description.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
133pub struct AssistantToolsDescriptor {
134 /// Whether this server's own general MCP endpoint is handed to the harness
135 /// — the aion tools an agent drives workflows with.
136 pub aion: bool,
137 /// The assistant's OWN tool server: the second MCP endpoint every session's
138 /// agent is handed, separate from the general one.
139 pub assistant: AssistantOwnToolsDescriptor,
140}
141
142/// The assistant-only MCP server handed to every session's agent.
143///
144/// Published so an operator can see, without reading the source, that a session
145/// hands its agent a tool for reading the console screen — what it is called,
146/// where it is served, and what credential it takes.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148pub struct AssistantOwnToolsDescriptor {
149 /// The name the agent shows for it.
150 pub server: String,
151 /// The route it is served on.
152 pub route: String,
153 /// Every tool in its catalogue. Exactly one, and it is not in the general
154 /// catalogue — a general caller asking for it is refused by name.
155 pub tools: Vec<String>,
156 /// Whether it is actually handed over on this server. `false` when no
157 /// dialable address can be stated (a configured port of zero).
158 pub handed_over: bool,
159 /// Why it is not handed over, or `None` when it is.
160 pub unavailable_reason: Option<String>,
161 /// The credential it accepts.
162 pub token: AssistantSessionTokenDescriptor,
163}
164
165/// The credential the assistant tool route accepts.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
167pub struct AssistantSessionTokenDescriptor {
168 /// The wire word for the kind, for a client that branches on it.
169 pub kind: String,
170 /// Who mints it: this server, never a caller.
171 pub minted_by: String,
172 /// What it authorizes: one session, never a namespace or a person.
173 pub scope: String,
174 /// The one sentence describing it, from
175 /// [`crate::assistant::mcp::SESSION_TOKEN_DESCRIPTION`] — the SAME sentence
176 /// `docs/operations/API.md` quotes, so there is one description of one
177 /// credential rather than two that could drift.
178 pub description: String,
179}
180
181impl AssistantSessionTokenDescriptor {
182 /// The description of the credential this binary mints.
183 #[must_use]
184 pub fn current() -> Self {
185 Self {
186 kind: crate::assistant::mcp::SESSION_TOKEN_KIND.to_owned(),
187 minted_by: TOKEN_MINTED_BY.to_owned(),
188 scope: TOKEN_SCOPE.to_owned(),
189 description: crate::assistant::mcp::SESSION_TOKEN_DESCRIPTION.to_owned(),
190 }
191 }
192}
193
194/// Who mints the session bearer. The server, always: a caller cannot present
195/// one it made, because verification is against a digest only the server wrote.
196const TOKEN_MINTED_BY: &str = "server";
197/// What the session bearer authorizes.
198const TOKEN_SCOPE: &str = "session";
199/// Why the assistant tool server is not handed over when it is not.
200const NO_DIALABLE_ADDRESS: &str = "this server cannot state an address an agent could dial back on (`server.listen_address` \
201 names port 0, whose real port is only known after bind), so no MCP server of ours is handed \
202 to a session's agent and it cannot read what is on the operator's screen";
203
204/// One grant word this deployment defines, and whether the caller reading this
205/// description holds it.
206///
207/// Built by walking [`GRANT_WORDS`], never a hand-written list: a word that
208/// existed in the grammar and not here would be grantable and undiscoverable.
209/// The `description` is the grammar row's own sentence, so the console states
210/// what a word authorises rather than inventing a meaning for it.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212pub struct AssistantGrantDescriptor {
213 /// The stable word an operator and an audit line spell.
214 pub name: String,
215 /// Whether this caller holds it.
216 pub held: bool,
217 /// One sentence naming what the word authorises, from the grammar row.
218 pub description: String,
219}
220
221/// The adapter kind every resolved harness runs.
222///
223/// [`crate::config::ResolvedAssistantHarness`] carries no kind because there is
224/// nothing per-harness left to remember: `config/assistant_resolve.rs` refuses
225/// every value but this one at load, so a RESOLVED harness is an ACP harness by
226/// construction. It is published anyway — a client must not have to know that
227/// rule to read this description — and pinned against the resolver itself by
228/// `the_published_harness_kind_is_the_one_resolution_accepts` in
229/// `api/http/assistant_sessions_tests.rs`, so the two cannot drift.
230const HARNESS_KIND: &str = "acp";
231
232/// The name the agent shows for the assistant's own tool server.
233///
234/// Pinned against `launch.rs`'s own constant by
235/// `the_published_assistant_server_name_is_the_one_a_spawn_hands_over`, so the
236/// description and the spawn cannot name two different servers.
237const ASSISTANT_TOOL_SERVER_NAME: &str = "assistant";
238
239/// The served description of the built-in assistant.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
241pub struct AssistantDescriptor {
242 /// The workflow type an operator starts.
243 pub workflow_type: String,
244 /// The private task queue a worker must serve for a session to run.
245 ///
246 /// Published because an operator with no checkout has no other way to
247 /// learn it, and because getting it wrong is silent: a worker on the wrong
248 /// queue connects, registers, and never receives a dispatch. Derived from
249 /// the document's own identity and never `default` — see
250 /// [`super::document::private_task_queue`].
251 pub task_queue: String,
252 /// The embedded document's content hash — its version identity.
253 pub content_hash: String,
254 /// The document's filename inside the binary.
255 pub document_filename: &'static str,
256 /// Length of the embedded document in bytes, so a client can tell a
257 /// truncated fetch of `/assistant/document` from a complete one.
258 pub document_bytes: usize,
259 /// Start input schema, draft 2020-12, derived from the document.
260 pub input_schema: Value,
261 /// Every declared signal with its payload schema.
262 pub signals: Vec<AssistantSignal>,
263 /// Every declared query name, in document order.
264 pub queries: Vec<String>,
265 /// The verified session contract.
266 pub session: AssistantSessionContract,
267 /// Where the embedded version stands in the catalog right now.
268 pub residency: AssistantResidency,
269 /// Every harness this server can open an assistant session on — the
270 /// build's catalogue, in catalogue order, each with its availability on this
271 /// machine.
272 pub harnesses: Vec<AssistantHarnessDescriptor>,
273 /// The harness THIS caller last opened a session on, or `None` before they
274 /// have opened one.
275 ///
276 /// A memory, not a policy: it is written by `createSession` from what the
277 /// operator actually did, and it is caller-scoped, so one operator's habit
278 /// never preselects another's console. `None` is a complete answer — a
279 /// client preselects the first available entry rather than the server
280 /// inventing a choice nobody made.
281 pub default_harness: Option<String>,
282 /// The tool wiring every session on this server is handed.
283 pub tools: AssistantToolsDescriptor,
284 /// Whether this server can open an assistant session at all.
285 pub sessions_enabled: bool,
286 /// Why it cannot, in words an operator can act on, or `None` when it can.
287 ///
288 /// Reserved for a refusal the PRODUCT can name — today, a durable store this
289 /// server could not read at start-up. It is never "not configured": a stock
290 /// server with no `[assistant]` section serves the assistant, and a harness
291 /// this machine cannot run is said per entry, with its install hint, rather
292 /// than as one sentence about the whole surface.
293 pub sessions_disabled_reason: Option<String>,
294 /// Every grant word this deployment defines, and whether the caller reading
295 /// this description holds it.
296 pub grants: Vec<AssistantGrantDescriptor>,
297}
298
299/// Describes `embedded` against `engine`'s current catalog, `sessions`'
300/// configuration and availability, and what `caller` is authorized to do.
301///
302/// Everything is DERIVED at request time and nothing is remembered from boot —
303/// including the grant rows, which are read off this request's own resolved
304/// identity, so one description can never report another caller's
305/// authorization.
306///
307/// # Errors
308///
309/// Whatever the store reports while reading this caller's last harness pick.
310/// The pick is a stored fact about the caller, so it is read rather than
311/// remembered, and a store that cannot answer is reported rather than being
312/// rendered as "no pick" — which is a different, and wrong, thing to tell a
313/// console.
314pub async fn describe(
315 embedded: &EmbeddedAssistant,
316 engine: &Engine,
317 sessions: &AssistantSessions,
318 caller: &CallerIdentity,
319) -> Result<AssistantDescriptor, AssistantSessionError> {
320 let config = sessions.config();
321 let availability = sessions.availability();
322 let default_harness = sessions.last_harness_pick(caller.subject()).await?;
323 Ok(AssistantDescriptor {
324 workflow_type: embedded.workflow_type().to_owned(),
325 task_queue: embedded.task_queue().to_owned(),
326 content_hash: embedded.content_hash().to_string(),
327 document_filename: EMBEDDED_ASSISTANT_FILENAME,
328 document_bytes: embedded.source().len(),
329 input_schema: embedded.input_schema().clone(),
330 signals: embedded
331 .signals()
332 .iter()
333 .map(|signal| AssistantSignal {
334 name: signal.name.clone(),
335 input_schema: signal.input_schema.clone(),
336 })
337 .collect(),
338 queries: embedded.queries().to_vec(),
339 session: AssistantSessionContract::current(),
340 residency: residency(embedded, engine),
341 harnesses: catalogue::CATALOGUE
342 .iter()
343 .map(|entry| AssistantHarnessDescriptor {
344 name: entry.id.to_owned(),
345 kind: HARNESS_KIND.to_owned(),
346 accounts: config.account_names(entry.id),
347 // MEASURED here, on every read. The hint is carried exactly when
348 // it is needed, so a client cannot render "install Node.js"
349 // beside a harness that is already running.
350 available: entry.available(),
351 install_hint: (!entry.available()).then(|| entry.install_hint.to_owned()),
352 launch: entry.launch(),
353 })
354 .collect(),
355 default_harness,
356 tools: AssistantToolsDescriptor {
357 aion: sessions.hands_over_general_mcp(),
358 assistant: AssistantOwnToolsDescriptor {
359 server: ASSISTANT_TOOL_SERVER_NAME.to_owned(),
360 route: crate::assistant::mcp::ASSISTANT_MCP_PATH.to_owned(),
361 tools: crate::assistant::mcp::SESSION_TOOL_NAMES
362 .iter()
363 .map(|name| (*name).to_owned())
364 .collect(),
365 handed_over: sessions.hands_over_assistant_tools(),
366 unavailable_reason: (!sessions.hands_over_assistant_tools())
367 .then(|| NO_DIALABLE_ADDRESS.to_owned()),
368 token: AssistantSessionTokenDescriptor::current(),
369 },
370 },
371 sessions_enabled: availability.is_available(),
372 sessions_disabled_reason: availability.reason().map(ToOwned::to_owned),
373 grants: GRANT_WORDS
374 .iter()
375 .map(|grant| AssistantGrantDescriptor {
376 name: grant.word().to_owned(),
377 held: grant.granted_for(caller),
378 description: grant.description().to_owned(),
379 })
380 .collect(),
381 })
382}
383
384/// Reads the catalog and classifies the embedded version's standing.
385fn residency(embedded: &EmbeddedAssistant, engine: &Engine) -> AssistantResidency {
386 let versions = match engine.list_workflow_versions() {
387 Ok(versions) => versions,
388 Err(error) => {
389 return AssistantResidency::Unknown {
390 reason: format!("the engine catalog could not be read: {error}"),
391 };
392 }
393 };
394 let embedded_hash = embedded.content_hash().to_string();
395 let resident: Vec<_> = versions
396 .into_iter()
397 .filter(|version| version.workflow_type == embedded.workflow_type())
398 .collect();
399 let loaded = resident
400 .iter()
401 .any(|version| version.content_hash.to_string() == embedded_hash);
402 let routed_hash = resident
403 .iter()
404 .find(|version| version.route_active)
405 .map(|version| version.content_hash.to_string());
406 if !loaded {
407 return AssistantResidency::NotLoaded { routed_hash };
408 }
409 if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
410 AssistantResidency::Routed
411 } else {
412 AssistantResidency::LoadedNotRouted { routed_hash }
413 }
414}