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 serde::Serialize;
11use serde_json::Value;
12
13use super::document::{
14    CONTINUE_END_FIELD, CONTINUE_MESSAGE_FIELD, CONTINUE_SIGNAL, EMBEDDED_ASSISTANT_FILENAME,
15    EmbeddedAssistant, OBJECTIVE_INPUT, REPO_PATH_INPUT, STATUS_QUERY,
16};
17
18/// Where the embedded document stands in the engine catalog right now.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case", tag = "state")]
21pub enum AssistantResidency {
22    /// The embedded version is loaded and holds the route: starting the
23    /// assistant runs this document.
24    Routed,
25    /// The embedded version is loaded but another version holds the route (or
26    /// nothing does). Starting the assistant does not run this document.
27    LoadedNotRouted {
28        /// The routed hash, when some resident version holds the route.
29        routed_hash: Option<String>,
30    },
31    /// The embedded version is not in the catalog at all. `routed_hash` names
32    /// whatever version of the type does hold the route; `None` means the type
33    /// is unstartable on this server.
34    NotLoaded {
35        /// The routed hash, when some other version holds the route.
36        routed_hash: Option<String>,
37    },
38    /// The catalog could not be read, so residency is unknown — never guessed.
39    Unknown {
40        /// Why the catalog could not be read.
41        reason: String,
42    },
43}
44
45/// The names the operator surfaces bind to, verified against the document at
46/// load time. Published so a client drives the session by reading rather than
47/// by restating them.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct AssistantSessionContract {
50    /// Start input carrying the operator's opening ask.
51    pub objective_input: &'static str,
52    /// Start input carrying the repository path (empty = scratch mode).
53    pub repo_path_input: &'static str,
54    /// The signal a parked session listens on.
55    pub continue_signal: &'static str,
56    /// Continuation field carrying the next prompt.
57    pub message_field: &'static str,
58    /// Continuation field that ends the session.
59    pub end_field: &'static str,
60    /// The read-only status query.
61    pub status_query: &'static str,
62}
63
64impl AssistantSessionContract {
65    /// The contract this binary was built with.
66    #[must_use]
67    pub const fn current() -> Self {
68        Self {
69            objective_input: OBJECTIVE_INPUT,
70            repo_path_input: REPO_PATH_INPUT,
71            continue_signal: CONTINUE_SIGNAL,
72            message_field: CONTINUE_MESSAGE_FIELD,
73            end_field: CONTINUE_END_FIELD,
74            status_query: STATUS_QUERY,
75        }
76    }
77}
78
79/// One declared signal and its payload schema.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
81pub struct AssistantSignal {
82    /// Signal name.
83    pub name: String,
84    /// Payload schema, draft 2020-12, derived from the document.
85    pub input_schema: Value,
86}
87
88/// The served description of the built-in assistant.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90pub struct AssistantDescriptor {
91    /// The workflow type an operator starts.
92    pub workflow_type: String,
93    /// The embedded document's content hash — its version identity.
94    pub content_hash: String,
95    /// The document's filename inside the binary.
96    pub document_filename: &'static str,
97    /// Length of the embedded document in bytes, so a client can tell a
98    /// truncated fetch of `/assistant/document` from a complete one.
99    pub document_bytes: usize,
100    /// Start input schema, draft 2020-12, derived from the document.
101    pub input_schema: Value,
102    /// Every declared signal with its payload schema.
103    pub signals: Vec<AssistantSignal>,
104    /// Every declared query name, in document order.
105    pub queries: Vec<String>,
106    /// The verified session contract.
107    pub session: AssistantSessionContract,
108    /// Where the embedded version stands in the catalog right now.
109    pub residency: AssistantResidency,
110}
111
112/// Describes `embedded` against `engine`'s current catalog.
113#[must_use]
114pub fn describe(embedded: &EmbeddedAssistant, engine: &Engine) -> AssistantDescriptor {
115    AssistantDescriptor {
116        workflow_type: embedded.workflow_type().to_owned(),
117        content_hash: embedded.content_hash().to_string(),
118        document_filename: EMBEDDED_ASSISTANT_FILENAME,
119        document_bytes: embedded.source().len(),
120        input_schema: embedded.input_schema().clone(),
121        signals: embedded
122            .signals()
123            .iter()
124            .map(|signal| AssistantSignal {
125                name: signal.name.clone(),
126                input_schema: signal.input_schema.clone(),
127            })
128            .collect(),
129        queries: embedded.queries().to_vec(),
130        session: AssistantSessionContract::current(),
131        residency: residency(embedded, engine),
132    }
133}
134
135/// Reads the catalog and classifies the embedded version's standing.
136fn residency(embedded: &EmbeddedAssistant, engine: &Engine) -> AssistantResidency {
137    let versions = match engine.list_workflow_versions() {
138        Ok(versions) => versions,
139        Err(error) => {
140            return AssistantResidency::Unknown {
141                reason: format!("the engine catalog could not be read: {error}"),
142            };
143        }
144    };
145    let embedded_hash = embedded.content_hash().to_string();
146    let resident: Vec<_> = versions
147        .into_iter()
148        .filter(|version| version.workflow_type == embedded.workflow_type())
149        .collect();
150    let loaded = resident
151        .iter()
152        .any(|version| version.content_hash.to_string() == embedded_hash);
153    let routed_hash = resident
154        .iter()
155        .find(|version| version.route_active)
156        .map(|version| version.content_hash.to_string());
157    if !loaded {
158        return AssistantResidency::NotLoaded { routed_hash };
159    }
160    if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
161        AssistantResidency::Routed
162    } else {
163        AssistantResidency::LoadedNotRouted { routed_hash }
164    }
165}