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//!
23//! # The task queue is DERIVED, and it is never `default`
24//!
25//! The built-in assistant owns a private queue named after itself:
26//! [`private_task_queue`] IS the rule, and the queue is that function of the
27//! document's own workflow type — not a constant beside it, not a
28//! configuration key with a default. [`EmbeddedAssistant::load`] proves the
29//! document declares exactly that one queue and refuses it otherwise, so the
30//! derivation cannot drift from the document and the document cannot drift
31//! from the server.
32//!
33//! `default` is where an out-of-box worker comes up when nobody has told it
34//! otherwise. Contract admission holds a registering worker against EVERY
35//! reachable contract on its queue at once, so while the assistant sat on
36//! `default` the first worker a newcomer started was refused for not
37//! advertising `assistant` — the built-in assistant starved the workers it
38//! exists to welcome (#200). The queue therefore belongs to those workers, and
39//! this document never claims it.
40
41use std::path::Path;
42use std::sync::OnceLock;
43
44use aion_awl::{CompiledWorkflow, TypeBody};
45use aion_package::{
46 ContentHash, ExtractionLimits, Package, PackageContract, PackageError, SignalContract,
47};
48use serde_json::Value;
49
50/// The embedded assistant document, compiled into the binary.
51///
52/// `include_str!` from inside the crate, exactly as the ops-console bundle is
53/// embedded from `ops-console-embed/`: the file is git-tracked under the crate
54/// root, so `cargo package` carries it and an installed binary holds the same
55/// bytes this repository does.
56pub const EMBEDDED_ASSISTANT_DOCUMENT: &str = include_str!("../../assistant-embed/assistant.awl");
57
58/// The document's own filename, recorded in the assembled archive's `awl/`
59/// provenance tree so a deployed package carries the source it was built from.
60pub const EMBEDDED_ASSISTANT_FILENAME: &str = "assistant.awl";
61
62/// The start input carrying the operator's opening ask.
63pub const OBJECTIVE_INPUT: &str = "objective";
64
65/// The start input carrying the repository the session grounds itself in.
66/// The document's documented scratch mode is the empty string.
67pub const REPO_PATH_INPUT: &str = "repo_path";
68
69/// The one control signal a parked session listens on.
70pub const CONTINUE_SIGNAL: &str = "assistant_continue";
71
72/// The continuation field carrying the operator's next prompt.
73pub const CONTINUE_MESSAGE_FIELD: &str = "message";
74
75/// The continuation field that ends the session cleanly.
76pub const CONTINUE_END_FIELD: &str = "end";
77
78/// The read-only query reporting a live session's phase and round count.
79pub const STATUS_QUERY: &str = "assistant_status";
80
81/// The assistant's private task queue, derived from its own identity.
82///
83/// The derivation IS the value: the built-in assistant serves the queue named
84/// after the workflow type it exports, and there is nowhere else a queue name
85/// is written down. [`EmbeddedAssistant::load`] holds the embedded document to
86/// this — a document declaring any other queue does not load — so the server,
87/// the document, and every surface that reports the queue read one rule.
88///
89/// It is deliberately NOT operator-configurable and has no default to fall
90/// back to. A configurable queue with a default is how the assistant ended up
91/// on `default` in the first place.
92#[must_use]
93pub const fn private_task_queue(workflow_type: &str) -> &str {
94 workflow_type
95}
96
97/// The schema-import root presented to the compiler.
98///
99/// The document is embedded as bytes with no directory beside it, so it cannot
100/// carry `schema(…)` imports — [`EmbeddedAssistant::load`] refuses one before
101/// compiling. This root is therefore never read; it is a path that does not
102/// exist so that a future import would fail loudly here rather than silently
103/// resolve against whatever directory the server happens to be running in.
104const EMBEDDED_SCHEMA_ROOT: &str = "<embedded-assistant-has-no-schema-directory>";
105
106/// A refusal to produce the embedded assistant.
107///
108/// Every variant names what about the document made it unusable. There is no
109/// "assistant unavailable" catch-all: an operator reading a boot log or an
110/// `/assistant` error must be able to act on it.
111#[derive(Debug, thiserror::Error)]
112pub enum EmbeddedAssistantError {
113 /// The embedded document does not parse.
114 #[error("the embedded assistant document does not parse: {message}")]
115 Parse {
116 /// The parser's diagnostic, verbatim.
117 message: String,
118 },
119
120 /// The embedded document carries a `schema(…)` import.
121 ///
122 /// The binary embeds one file and no directory, so an import has nothing to
123 /// resolve against. This is a refusal to guess, not a limitation dressed up
124 /// as one: inlining the type into the document removes it.
125 #[error(
126 "the embedded assistant document imports schema `{path}`, but the binary embeds the \
127 document alone and has no directory to resolve imports against; declare the type \
128 inline in the document"
129 )]
130 SchemaImport {
131 /// The import path, verbatim from the document.
132 path: String,
133 },
134
135 /// The embedded document does not compile.
136 #[error("the embedded assistant document does not compile: {message}")]
137 Compile {
138 /// The compiler's diagnostic, verbatim.
139 message: String,
140 },
141
142 /// The compiled document could not be assembled into an archive.
143 #[error("the embedded assistant document could not be packaged: {message}")]
144 Assemble {
145 /// The assembler's diagnostic, verbatim.
146 message: String,
147 },
148
149 /// The assembled archive did not load back as a validated package.
150 #[error("the embedded assistant package did not validate: {source}")]
151 Package {
152 /// The package validation failure.
153 #[from]
154 source: PackageError,
155 },
156
157 /// The document does not declare an input the session contract names.
158 #[error(
159 "the embedded assistant document declares no `{name}` input; the session contract in \
160 crates/aion-server/src/assistant/document.rs names it, so document and contract have \
161 diverged"
162 )]
163 MissingInput {
164 /// The input the contract names.
165 name: &'static str,
166 },
167
168 /// The document does not declare the continuation signal.
169 #[error(
170 "the embedded assistant document declares no `{name}` signal; the session contract in \
171 crates/aion-server/src/assistant/document.rs names it, so document and contract have \
172 diverged"
173 )]
174 MissingSignal {
175 /// The signal the contract names.
176 name: &'static str,
177 },
178
179 /// The continuation signal's payload lacks a field the contract sends.
180 #[error(
181 "the `{signal}` signal payload declares no `{field}` field; the session contract in \
182 crates/aion-server/src/assistant/document.rs sends it, so document and contract have \
183 diverged"
184 )]
185 MissingSignalField {
186 /// The signal whose payload was inspected.
187 signal: &'static str,
188 /// The field the contract sends.
189 field: &'static str,
190 },
191
192 /// The document does not declare the status query.
193 #[error(
194 "the embedded assistant document declares no `{name}` query; the session contract in \
195 crates/aion-server/src/assistant/document.rs names it, so document and contract have \
196 diverged"
197 )]
198 MissingQuery {
199 /// The query the contract names.
200 name: &'static str,
201 },
202
203 /// The compiled package declares no signal contract at all.
204 #[error(
205 "the embedded assistant package carries no contract, so its signal payloads cannot be \
206 read: {message}"
207 )]
208 MissingContract {
209 /// Why the contract could not be read.
210 message: String,
211 },
212
213 /// The document's own workflow type would derive the out-of-box workers'
214 /// queue, so the assistant cannot have a private queue at all.
215 #[error(
216 "the embedded assistant exports workflow type `{workflow_type}`, so its derived private \
217 queue would be `{default_queue}` — the queue every out-of-box worker comes up on. The \
218 built-in assistant never claims it (#200); the workflow must be named something else"
219 )]
220 QueueWouldBeDefault {
221 /// The workflow type the document exports.
222 workflow_type: String,
223 /// The out-of-box workers' queue this would collide with.
224 default_queue: &'static str,
225 },
226
227 /// The document declares no `worker` block, so it claims no queue.
228 #[error(
229 "the embedded assistant document declares no `worker` block, so it claims no task queue; \
230 the assistant's queue is derived from its own workflow type and the document must \
231 declare `worker {expected}`"
232 )]
233 MissingWorkerBlock {
234 /// The queue the derivation requires.
235 expected: String,
236 },
237
238 /// The document declares more than one `worker` block, so "the assistant's
239 /// queue" is not one value.
240 #[error(
241 "the embedded assistant document declares more than one `worker` block (`{declared}`); \
242 the built-in assistant serves exactly one derived private queue, `{expected}`"
243 )]
244 AmbiguousQueue {
245 /// Every declared queue, in declaration order, comma-separated.
246 declared: String,
247 /// The queue the derivation requires.
248 expected: String,
249 },
250
251 /// The document declares a queue other than its derived private one.
252 #[error(
253 "the embedded assistant document declares task queue `{declared}`, but the assistant's \
254 queue is derived from its own workflow type and must be `{expected}`. `default` is the \
255 out-of-box workers' queue and the built-in assistant never claims it (#200)"
256 )]
257 QueueNotDerived {
258 /// The queue the document declares.
259 declared: String,
260 /// The queue the derivation requires.
261 expected: String,
262 },
263}
264
265/// The embedded assistant: the document's bytes, the package compiled from
266/// them, and the contract surfaces an operator drives it through.
267///
268/// Construction is the verification: holding one of these is proof that the
269/// embedded document compiled, packaged, and carries every surface the session
270/// contract names.
271#[derive(Debug, Clone)]
272pub struct EmbeddedAssistant {
273 source: &'static str,
274 package: Package,
275 workflow_type: String,
276 task_queue: String,
277 input_schema: Value,
278 signals: Vec<SignalContract>,
279 queries: Vec<String>,
280}
281
282impl EmbeddedAssistant {
283 /// Compiles, packages, and verifies the embedded document.
284 ///
285 /// # Errors
286 ///
287 /// Returns [`EmbeddedAssistantError`] naming the stage that refused: parse,
288 /// a schema import the binary cannot carry, compilation, archive assembly,
289 /// package validation, or a session-contract surface the document does not
290 /// declare.
291 pub fn load() -> Result<Self, EmbeddedAssistantError> {
292 Self::from_source(EMBEDDED_ASSISTANT_DOCUMENT)
293 }
294
295 /// The whole preparation, over an arbitrary document.
296 ///
297 /// [`Self::load`] is this applied to the embedded bytes. It is separate so
298 /// the session-contract verification can be exercised against a document
299 /// that deliberately omits a surface — a check nothing ever fails is a
300 /// check nobody has measured.
301 ///
302 /// # Errors
303 ///
304 /// As [`Self::load`].
305 pub fn from_source(source: &'static str) -> Result<Self, EmbeddedAssistantError> {
306 let document = aion_awl::parse(source).map_err(|error| EmbeddedAssistantError::Parse {
307 message: error.message,
308 })?;
309 for declaration in &document.types {
310 if let TypeBody::SchemaImport { path, .. } = &declaration.body {
311 return Err(EmbeddedAssistantError::SchemaImport { path: path.clone() });
312 }
313 }
314
315 let root = Path::new(EMBEDDED_SCHEMA_ROOT);
316 let prepared =
317 aion_awl_package::compile_and_assemble_awl(source, root, EMBEDDED_ASSISTANT_FILENAME)
318 .map_err(|error| match error {
319 aion_awl_package::PrepareAwlError::Compile(compile) => {
320 EmbeddedAssistantError::Compile {
321 message: compile.to_string(),
322 }
323 }
324 other => EmbeddedAssistantError::Assemble {
325 message: other.to_string(),
326 },
327 })?;
328 let CompiledWorkflow { input_schema, .. } = prepared.compiled;
329
330 // Trusted, compile-time content assembled by this process moments ago —
331 // not network input — so extraction carries no inflate ceiling
332 // (`ExtractionLimits::unbounded`'s stated use).
333 let package = Package::load_from_bytes(&prepared.archive, ExtractionLimits::unbounded())?;
334 let workflow_type = package.manifest().entry_module.clone();
335
336 let contract =
337 package
338 .contract()
339 .map_err(|error| EmbeddedAssistantError::MissingContract {
340 message: error.to_string(),
341 })?;
342 let signals = contract.signals.clone();
343 let task_queue = verified_task_queue(&workflow_type, contract)?;
344
345 for name in [OBJECTIVE_INPUT, REPO_PATH_INPUT] {
346 if !document.inputs.iter().any(|input| input.name == name) {
347 return Err(EmbeddedAssistantError::MissingInput { name });
348 }
349 }
350 let continuation = signals
351 .iter()
352 .find(|signal| signal.name == CONTINUE_SIGNAL)
353 .ok_or(EmbeddedAssistantError::MissingSignal {
354 name: CONTINUE_SIGNAL,
355 })?;
356 for field in [CONTINUE_MESSAGE_FIELD, CONTINUE_END_FIELD] {
357 if !schema_declares_property(&continuation.input_schema, field) {
358 return Err(EmbeddedAssistantError::MissingSignalField {
359 signal: CONTINUE_SIGNAL,
360 field,
361 });
362 }
363 }
364 let queries: Vec<String> = document
365 .queries
366 .iter()
367 .map(|query| query.name.clone())
368 .collect();
369 if !queries.iter().any(|name| name == STATUS_QUERY) {
370 return Err(EmbeddedAssistantError::MissingQuery { name: STATUS_QUERY });
371 }
372
373 Ok(Self {
374 source,
375 package,
376 workflow_type,
377 task_queue,
378 input_schema,
379 signals,
380 queries,
381 })
382 }
383
384 /// The validated package the engine loads.
385 #[must_use]
386 pub const fn package(&self) -> &Package {
387 &self.package
388 }
389
390 /// The workflow type an operator starts.
391 #[must_use]
392 pub fn workflow_type(&self) -> &str {
393 &self.workflow_type
394 }
395
396 /// The assistant's private task queue: the one a worker must serve for a
397 /// session to run, and never `default`.
398 ///
399 /// Verified at load against [`private_task_queue`], so this is the
400 /// document's own declaration and the derivation at once — holding an
401 /// [`EmbeddedAssistant`] is proof they agree.
402 #[must_use]
403 pub fn task_queue(&self) -> &str {
404 &self.task_queue
405 }
406
407 /// The package's content hash — this document's version identity.
408 #[must_use]
409 pub const fn content_hash(&self) -> &ContentHash {
410 self.package.content_hash()
411 }
412
413 /// The document source, verbatim.
414 #[must_use]
415 pub const fn source(&self) -> &'static str {
416 self.source
417 }
418
419 /// The derived JSON Schema of the start input.
420 #[must_use]
421 pub const fn input_schema(&self) -> &Value {
422 &self.input_schema
423 }
424
425 /// Every declared signal with its payload schema.
426 #[must_use]
427 pub fn signals(&self) -> &[SignalContract] {
428 &self.signals
429 }
430
431 /// Every declared query name, in document order.
432 #[must_use]
433 pub fn queries(&self) -> &[String] {
434 &self.queries
435 }
436
437 /// The continuation signal's payload schema.
438 ///
439 /// Present by construction: [`Self::load`] refuses a document that does not
440 /// declare [`CONTINUE_SIGNAL`].
441 ///
442 /// # Errors
443 ///
444 /// Returns [`EmbeddedAssistantError::MissingSignal`] if the signal set is
445 /// ever mutated out from under construction — reported rather than assumed
446 /// away.
447 pub fn continuation_schema(&self) -> Result<&Value, EmbeddedAssistantError> {
448 self.signals
449 .iter()
450 .find(|signal| signal.name == CONTINUE_SIGNAL)
451 .map(|signal| &signal.input_schema)
452 .ok_or(EmbeddedAssistantError::MissingSignal {
453 name: CONTINUE_SIGNAL,
454 })
455 }
456}
457
458/// The queue the compiled document declares, proved equal to the derivation.
459///
460/// Four ways this refuses, and each is a refusal to guess rather than a
461/// limitation: a workflow type whose derived queue would be the out-of-box
462/// workers' queue, a document that declares no queue at all, one that declares
463/// several (so "the assistant's queue" is not one value), and one that declares
464/// a queue the derivation did not produce.
465fn verified_task_queue(
466 workflow_type: &str,
467 contract: &PackageContract,
468) -> Result<String, EmbeddedAssistantError> {
469 let expected = private_task_queue(workflow_type);
470 if expected == aion_core::DEFAULT_TASK_QUEUE {
471 return Err(EmbeddedAssistantError::QueueWouldBeDefault {
472 workflow_type: workflow_type.to_owned(),
473 default_queue: aion_core::DEFAULT_TASK_QUEUE,
474 });
475 }
476 let declared: Vec<&str> = contract
477 .workers
478 .iter()
479 .map(|worker| worker.task_queue.as_str())
480 .collect();
481 let [only] = declared.as_slice() else {
482 return Err(if declared.is_empty() {
483 EmbeddedAssistantError::MissingWorkerBlock {
484 expected: expected.to_owned(),
485 }
486 } else {
487 EmbeddedAssistantError::AmbiguousQueue {
488 declared: declared.join(", "),
489 expected: expected.to_owned(),
490 }
491 });
492 };
493 if *only != expected {
494 return Err(EmbeddedAssistantError::QueueNotDerived {
495 declared: (*only).to_owned(),
496 expected: expected.to_owned(),
497 });
498 }
499 Ok(expected.to_owned())
500}
501
502/// Whether `schema` declares `property` under `properties`.
503///
504/// A signal payload's derived schema for a NAMED type is a reference beside its
505/// own definitions — `{"$ref": "#/$defs/Continuation", "$defs": {…}}` — so the
506/// properties live one hop away. That one local form is followed; anything else
507/// is left unresolved rather than guessed at, because a guess here would report
508/// a field the payload does not carry and the operator verbs would send it.
509fn schema_declares_property(schema: &Value, property: &str) -> bool {
510 resolve_local_ref(schema)
511 .and_then(|resolved| resolved.get("properties"))
512 .and_then(Value::as_object)
513 .is_some_and(|properties| properties.contains_key(property))
514}
515
516/// Follows a top-level `#/$defs/<name>` reference into the sibling `$defs` map.
517/// A schema with no `$ref` is already the definition; an unresolvable reference
518/// yields `None`.
519fn resolve_local_ref(schema: &Value) -> Option<&Value> {
520 let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
521 return Some(schema);
522 };
523 let name = reference.strip_prefix("#/$defs/")?;
524 schema.get("$defs")?.get(name)
525}
526
527/// The process-wide embedded assistant, compiled once on first use.
528///
529/// The document is compile-time constant, so its compiled form is too: every
530/// caller (boot install, `/assistant`, the operator verbs) reads this one
531/// value, and a failure is computed once and reported identically everywhere.
532///
533/// # Errors
534///
535/// Returns the [`EmbeddedAssistantError`] from the single load attempt.
536pub fn embedded_assistant() -> Result<&'static EmbeddedAssistant, &'static EmbeddedAssistantError> {
537 static EMBEDDED: OnceLock<Result<EmbeddedAssistant, EmbeddedAssistantError>> = OnceLock::new();
538 EMBEDDED.get_or_init(EmbeddedAssistant::load).as_ref()
539}
540
541#[cfg(test)]
542#[path = "document_tests.rs"]
543mod document_tests;