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, 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}