aion_server/assistant/document.rs
1//! The embedded assistant document: its bytes, its compiled identity, and the
2//! session contract the operator verbs bind to.
3//!
4//! # One document, one place
5//!
6//! `crates/aion-server/assistant-embed/assistant.awl` is the ONLY copy of the
7//! assistant document in this repository. It is not a copy of an example — the
8//! example was moved here, so there is no second file to drift from. Everything
9//! downstream (the boot install, the `/assistant` description, the `aion
10//! assistant` verbs, the ops-console contract test) reads this one artifact.
11//!
12//! # The session contract is named once and VERIFIED, never restated
13//!
14//! An operator verb has to know which input carries the objective, which signal
15//! continues the session, and which fields that signal's payload takes. Those
16//! names are declared here exactly once, as constants, and [`EmbeddedAssistant::load`]
17//! proves each one against the compiled document before returning. A document
18//! edit that renames `objective`, drops the `assistant_continue` signal, or
19//! changes the continuation's fields therefore fails loudly at load — at boot,
20//! in the verb, and in this module's own tests — instead of leaving a constant
21//! quietly disagreeing with the document it names.
22
23use std::path::Path;
24use std::sync::OnceLock;
25
26use aion_awl::{CompiledWorkflow, TypeBody};
27use aion_package::{ContentHash, ExtractionLimits, Package, PackageError, SignalContract};
28use serde_json::Value;
29
30/// The embedded assistant document, compiled into the binary.
31///
32/// `include_str!` from inside the crate, exactly as the ops-console bundle is
33/// embedded from `ops-console-embed/`: the file is git-tracked under the crate
34/// root, so `cargo package` carries it and an installed binary holds the same
35/// bytes this repository does.
36pub const EMBEDDED_ASSISTANT_DOCUMENT: &str = include_str!("../../assistant-embed/assistant.awl");
37
38/// The document's own filename, recorded in the assembled archive's `awl/`
39/// provenance tree so a deployed package carries the source it was built from.
40pub const EMBEDDED_ASSISTANT_FILENAME: &str = "assistant.awl";
41
42/// The start input carrying the operator's opening ask.
43pub const OBJECTIVE_INPUT: &str = "objective";
44
45/// The start input carrying the repository the session grounds itself in.
46/// The document's documented scratch mode is the empty string.
47pub const REPO_PATH_INPUT: &str = "repo_path";
48
49/// The one control signal a parked session listens on.
50pub const CONTINUE_SIGNAL: &str = "assistant_continue";
51
52/// The continuation field carrying the operator's next prompt.
53pub const CONTINUE_MESSAGE_FIELD: &str = "message";
54
55/// The continuation field that ends the session cleanly.
56pub const CONTINUE_END_FIELD: &str = "end";
57
58/// The read-only query reporting a live session's phase and round count.
59pub const STATUS_QUERY: &str = "assistant_status";
60
61/// The schema-import root presented to the compiler.
62///
63/// The document is embedded as bytes with no directory beside it, so it cannot
64/// carry `schema(…)` imports — [`EmbeddedAssistant::load`] refuses one before
65/// compiling. This root is therefore never read; it is a path that does not
66/// exist so that a future import would fail loudly here rather than silently
67/// resolve against whatever directory the server happens to be running in.
68const EMBEDDED_SCHEMA_ROOT: &str = "<embedded-assistant-has-no-schema-directory>";
69
70/// A refusal to produce the embedded assistant.
71///
72/// Every variant names what about the document made it unusable. There is no
73/// "assistant unavailable" catch-all: an operator reading a boot log or an
74/// `/assistant` error must be able to act on it.
75#[derive(Debug, thiserror::Error)]
76pub enum EmbeddedAssistantError {
77 /// The embedded document does not parse.
78 #[error("the embedded assistant document does not parse: {message}")]
79 Parse {
80 /// The parser's diagnostic, verbatim.
81 message: String,
82 },
83
84 /// The embedded document carries a `schema(…)` import.
85 ///
86 /// The binary embeds one file and no directory, so an import has nothing to
87 /// resolve against. This is a refusal to guess, not a limitation dressed up
88 /// as one: inlining the type into the document removes it.
89 #[error(
90 "the embedded assistant document imports schema `{path}`, but the binary embeds the \
91 document alone and has no directory to resolve imports against; declare the type \
92 inline in the document"
93 )]
94 SchemaImport {
95 /// The import path, verbatim from the document.
96 path: String,
97 },
98
99 /// The embedded document does not compile.
100 #[error("the embedded assistant document does not compile: {message}")]
101 Compile {
102 /// The compiler's diagnostic, verbatim.
103 message: String,
104 },
105
106 /// The compiled document could not be assembled into an archive.
107 #[error("the embedded assistant document could not be packaged: {message}")]
108 Assemble {
109 /// The assembler's diagnostic, verbatim.
110 message: String,
111 },
112
113 /// The assembled archive did not load back as a validated package.
114 #[error("the embedded assistant package did not validate: {source}")]
115 Package {
116 /// The package validation failure.
117 #[from]
118 source: PackageError,
119 },
120
121 /// The document does not declare an input the session contract names.
122 #[error(
123 "the embedded assistant document declares no `{name}` input; the session contract in \
124 crates/aion-server/src/assistant/document.rs names it, so document and contract have \
125 diverged"
126 )]
127 MissingInput {
128 /// The input the contract names.
129 name: &'static str,
130 },
131
132 /// The document does not declare the continuation signal.
133 #[error(
134 "the embedded assistant document declares no `{name}` signal; the session contract in \
135 crates/aion-server/src/assistant/document.rs names it, so document and contract have \
136 diverged"
137 )]
138 MissingSignal {
139 /// The signal the contract names.
140 name: &'static str,
141 },
142
143 /// The continuation signal's payload lacks a field the contract sends.
144 #[error(
145 "the `{signal}` signal payload declares no `{field}` field; the session contract in \
146 crates/aion-server/src/assistant/document.rs sends it, so document and contract have \
147 diverged"
148 )]
149 MissingSignalField {
150 /// The signal whose payload was inspected.
151 signal: &'static str,
152 /// The field the contract sends.
153 field: &'static str,
154 },
155
156 /// The document does not declare the status query.
157 #[error(
158 "the embedded assistant document declares no `{name}` query; the session contract in \
159 crates/aion-server/src/assistant/document.rs names it, so document and contract have \
160 diverged"
161 )]
162 MissingQuery {
163 /// The query the contract names.
164 name: &'static str,
165 },
166
167 /// The compiled package declares no signal contract at all.
168 #[error(
169 "the embedded assistant package carries no contract, so its signal payloads cannot be \
170 read: {message}"
171 )]
172 MissingContract {
173 /// Why the contract could not be read.
174 message: String,
175 },
176}
177
178/// The embedded assistant: the document's bytes, the package compiled from
179/// them, and the contract surfaces an operator drives it through.
180///
181/// Construction is the verification: holding one of these is proof that the
182/// embedded document compiled, packaged, and carries every surface the session
183/// contract names.
184#[derive(Debug, Clone)]
185pub struct EmbeddedAssistant {
186 source: &'static str,
187 package: Package,
188 workflow_type: String,
189 input_schema: Value,
190 signals: Vec<SignalContract>,
191 queries: Vec<String>,
192}
193
194impl EmbeddedAssistant {
195 /// Compiles, packages, and verifies the embedded document.
196 ///
197 /// # Errors
198 ///
199 /// Returns [`EmbeddedAssistantError`] naming the stage that refused: parse,
200 /// a schema import the binary cannot carry, compilation, archive assembly,
201 /// package validation, or a session-contract surface the document does not
202 /// declare.
203 pub fn load() -> Result<Self, EmbeddedAssistantError> {
204 Self::from_source(EMBEDDED_ASSISTANT_DOCUMENT)
205 }
206
207 /// The whole preparation, over an arbitrary document.
208 ///
209 /// [`Self::load`] is this applied to the embedded bytes. It is separate so
210 /// the session-contract verification can be exercised against a document
211 /// that deliberately omits a surface — a check nothing ever fails is a
212 /// check nobody has measured.
213 ///
214 /// # Errors
215 ///
216 /// As [`Self::load`].
217 pub fn from_source(source: &'static str) -> Result<Self, EmbeddedAssistantError> {
218 let document = aion_awl::parse(source).map_err(|error| EmbeddedAssistantError::Parse {
219 message: error.message,
220 })?;
221 for declaration in &document.types {
222 if let TypeBody::SchemaImport { path, .. } = &declaration.body {
223 return Err(EmbeddedAssistantError::SchemaImport { path: path.clone() });
224 }
225 }
226
227 let root = Path::new(EMBEDDED_SCHEMA_ROOT);
228 let prepared =
229 aion_awl_package::compile_and_assemble_awl(source, root, EMBEDDED_ASSISTANT_FILENAME)
230 .map_err(|error| match error {
231 aion_awl_package::PrepareAwlError::Compile(compile) => {
232 EmbeddedAssistantError::Compile {
233 message: compile.to_string(),
234 }
235 }
236 other => EmbeddedAssistantError::Assemble {
237 message: other.to_string(),
238 },
239 })?;
240 let CompiledWorkflow { input_schema, .. } = prepared.compiled;
241
242 // Trusted, compile-time content assembled by this process moments ago —
243 // not network input — so extraction carries no inflate ceiling
244 // (`ExtractionLimits::unbounded`'s stated use).
245 let package = Package::load_from_bytes(&prepared.archive, ExtractionLimits::unbounded())?;
246 let workflow_type = package.manifest().entry_module.clone();
247
248 let contract =
249 package
250 .contract()
251 .map_err(|error| EmbeddedAssistantError::MissingContract {
252 message: error.to_string(),
253 })?;
254 let signals = contract.signals.clone();
255
256 for name in [OBJECTIVE_INPUT, REPO_PATH_INPUT] {
257 if !document.inputs.iter().any(|input| input.name == name) {
258 return Err(EmbeddedAssistantError::MissingInput { name });
259 }
260 }
261 let continuation = signals
262 .iter()
263 .find(|signal| signal.name == CONTINUE_SIGNAL)
264 .ok_or(EmbeddedAssistantError::MissingSignal {
265 name: CONTINUE_SIGNAL,
266 })?;
267 for field in [CONTINUE_MESSAGE_FIELD, CONTINUE_END_FIELD] {
268 if !schema_declares_property(&continuation.input_schema, field) {
269 return Err(EmbeddedAssistantError::MissingSignalField {
270 signal: CONTINUE_SIGNAL,
271 field,
272 });
273 }
274 }
275 let queries: Vec<String> = document
276 .queries
277 .iter()
278 .map(|query| query.name.clone())
279 .collect();
280 if !queries.iter().any(|name| name == STATUS_QUERY) {
281 return Err(EmbeddedAssistantError::MissingQuery { name: STATUS_QUERY });
282 }
283
284 Ok(Self {
285 source,
286 package,
287 workflow_type,
288 input_schema,
289 signals,
290 queries,
291 })
292 }
293
294 /// The validated package the engine loads.
295 #[must_use]
296 pub const fn package(&self) -> &Package {
297 &self.package
298 }
299
300 /// The workflow type an operator starts.
301 #[must_use]
302 pub fn workflow_type(&self) -> &str {
303 &self.workflow_type
304 }
305
306 /// The package's content hash — this document's version identity.
307 #[must_use]
308 pub const fn content_hash(&self) -> &ContentHash {
309 self.package.content_hash()
310 }
311
312 /// The document source, verbatim.
313 #[must_use]
314 pub const fn source(&self) -> &'static str {
315 self.source
316 }
317
318 /// The derived JSON Schema of the start input.
319 #[must_use]
320 pub const fn input_schema(&self) -> &Value {
321 &self.input_schema
322 }
323
324 /// Every declared signal with its payload schema.
325 #[must_use]
326 pub fn signals(&self) -> &[SignalContract] {
327 &self.signals
328 }
329
330 /// Every declared query name, in document order.
331 #[must_use]
332 pub fn queries(&self) -> &[String] {
333 &self.queries
334 }
335
336 /// The continuation signal's payload schema.
337 ///
338 /// Present by construction: [`Self::load`] refuses a document that does not
339 /// declare [`CONTINUE_SIGNAL`].
340 ///
341 /// # Errors
342 ///
343 /// Returns [`EmbeddedAssistantError::MissingSignal`] if the signal set is
344 /// ever mutated out from under construction — reported rather than assumed
345 /// away.
346 pub fn continuation_schema(&self) -> Result<&Value, EmbeddedAssistantError> {
347 self.signals
348 .iter()
349 .find(|signal| signal.name == CONTINUE_SIGNAL)
350 .map(|signal| &signal.input_schema)
351 .ok_or(EmbeddedAssistantError::MissingSignal {
352 name: CONTINUE_SIGNAL,
353 })
354 }
355}
356
357/// Whether `schema` declares `property` under `properties`.
358///
359/// A signal payload's derived schema for a NAMED type is a reference beside its
360/// own definitions — `{"$ref": "#/$defs/Continuation", "$defs": {…}}` — so the
361/// properties live one hop away. That one local form is followed; anything else
362/// is left unresolved rather than guessed at, because a guess here would report
363/// a field the payload does not carry and the operator verbs would send it.
364fn schema_declares_property(schema: &Value, property: &str) -> bool {
365 resolve_local_ref(schema)
366 .and_then(|resolved| resolved.get("properties"))
367 .and_then(Value::as_object)
368 .is_some_and(|properties| properties.contains_key(property))
369}
370
371/// Follows a top-level `#/$defs/<name>` reference into the sibling `$defs` map.
372/// A schema with no `$ref` is already the definition; an unresolvable reference
373/// yields `None`.
374fn resolve_local_ref(schema: &Value) -> Option<&Value> {
375 let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
376 return Some(schema);
377 };
378 let name = reference.strip_prefix("#/$defs/")?;
379 schema.get("$defs")?.get(name)
380}
381
382/// The process-wide embedded assistant, compiled once on first use.
383///
384/// The document is compile-time constant, so its compiled form is too: every
385/// caller (boot install, `/assistant`, the operator verbs) reads this one
386/// value, and a failure is computed once and reported identically everywhere.
387///
388/// # Errors
389///
390/// Returns the [`EmbeddedAssistantError`] from the single load attempt.
391pub fn embedded_assistant() -> Result<&'static EmbeddedAssistant, &'static EmbeddedAssistantError> {
392 static EMBEDDED: OnceLock<Result<EmbeddedAssistant, EmbeddedAssistantError>> = OnceLock::new();
393 EMBEDDED.get_or_init(EmbeddedAssistant::load).as_ref()
394}
395
396#[cfg(test)]
397#[path = "document_tests.rs"]
398mod document_tests;