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 private task queue a worker must serve for a session to run.
94 ///
95 /// Published because an operator with no checkout has no other way to
96 /// learn it, and because getting it wrong is silent: a worker on the wrong
97 /// queue connects, registers, and never receives a dispatch. Derived from
98 /// the document's own identity and never `default` — see
99 /// [`super::document::private_task_queue`].
100 pub task_queue: String,
101 /// The embedded document's content hash — its version identity.
102 pub content_hash: String,
103 /// The document's filename inside the binary.
104 pub document_filename: &'static str,
105 /// Length of the embedded document in bytes, so a client can tell a
106 /// truncated fetch of `/assistant/document` from a complete one.
107 pub document_bytes: usize,
108 /// Start input schema, draft 2020-12, derived from the document.
109 pub input_schema: Value,
110 /// Every declared signal with its payload schema.
111 pub signals: Vec<AssistantSignal>,
112 /// Every declared query name, in document order.
113 pub queries: Vec<String>,
114 /// The verified session contract.
115 pub session: AssistantSessionContract,
116 /// Where the embedded version stands in the catalog right now.
117 pub residency: AssistantResidency,
118}
119
120/// Describes `embedded` against `engine`'s current catalog.
121#[must_use]
122pub fn describe(embedded: &EmbeddedAssistant, engine: &Engine) -> AssistantDescriptor {
123 AssistantDescriptor {
124 workflow_type: embedded.workflow_type().to_owned(),
125 task_queue: embedded.task_queue().to_owned(),
126 content_hash: embedded.content_hash().to_string(),
127 document_filename: EMBEDDED_ASSISTANT_FILENAME,
128 document_bytes: embedded.source().len(),
129 input_schema: embedded.input_schema().clone(),
130 signals: embedded
131 .signals()
132 .iter()
133 .map(|signal| AssistantSignal {
134 name: signal.name.clone(),
135 input_schema: signal.input_schema.clone(),
136 })
137 .collect(),
138 queries: embedded.queries().to_vec(),
139 session: AssistantSessionContract::current(),
140 residency: residency(embedded, engine),
141 }
142}
143
144/// Reads the catalog and classifies the embedded version's standing.
145fn residency(embedded: &EmbeddedAssistant, engine: &Engine) -> AssistantResidency {
146 let versions = match engine.list_workflow_versions() {
147 Ok(versions) => versions,
148 Err(error) => {
149 return AssistantResidency::Unknown {
150 reason: format!("the engine catalog could not be read: {error}"),
151 };
152 }
153 };
154 let embedded_hash = embedded.content_hash().to_string();
155 let resident: Vec<_> = versions
156 .into_iter()
157 .filter(|version| version.workflow_type == embedded.workflow_type())
158 .collect();
159 let loaded = resident
160 .iter()
161 .any(|version| version.content_hash.to_string() == embedded_hash);
162 let routed_hash = resident
163 .iter()
164 .find(|version| version.route_active)
165 .map(|version| version.content_hash.to_string());
166 if !loaded {
167 return AssistantResidency::NotLoaded { routed_hash };
168 }
169 if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
170 AssistantResidency::Routed
171 } else {
172 AssistantResidency::LoadedNotRouted { routed_hash }
173 }
174}