Skip to main content

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;