Skip to main content

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}