aion-server 0.30.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! Composing the prompt a turn actually sends.
//!
//! The operator types a sentence; the agent is asked that sentence PLUS what was
//! on the operator's screen.
//!
//! # 🔴 This is a MIRROR, and the mirror is the point
//!
//! The console renders the same prose before the operator sends it, so what they
//! read is what the agent is asked — and so they can decline it. There are
//! therefore two implementations of one format, in two languages, and the only
//! thing that keeps them from drifting is that both are pinned against the same
//! literal fixtures. The console's is
//! `apps/aion-ops-console/src/features/assistant/lib/turn-context.ts`
//! (`formatTurnContext`); this is its byte-for-byte twin, and
//! `prompt_tests.rs` carries the fixtures copied out of it. A change on either
//! side that is not made on both turns one of those fixtures red.
//!
//! # Why prose at all, when there is a tool
//!
//! The session's harness can also FETCH the context through the
//! `assistant_context` MCP tool, which is richer and always current. The prose
//! stays because the first turn has to work before the agent has called
//! anything: an agent that has not yet decided to look something up must still
//! know which screen the question came from.
//!
//! # Zero-based on the wire, one-based in the prose
//!
//! A document position is zero-based in an editor's model and one-based on its
//! gutter. The wire carries the model's numbers; this is the ONE place `+ 1`
//! happens, so the operator reads the numbers they can see.

use aion_core::{
    AssistantDocumentContext, AssistantDocumentPosition, AssistantDocumentSelection,
    AssistantTurnContext,
};

/// Compose the prompt for one turn: the context prose, then the operator's own
/// text.
///
/// An EMPTY context produces the operator's text unchanged — not a prefix
/// saying "nothing on screen", which would be noise the agent reads past on
/// every turn from a surface that carries no context at all.
#[must_use]
pub(crate) fn compose(context: Option<&AssistantTurnContext>, text: &str) -> String {
    let prefix = context.map(format_turn_context).unwrap_or_default();
    format!("{prefix}{text}")
}

/// The context as the prose that goes in front of the prompt.
///
/// The twin of the console's `formatTurnContext`: blocks joined by one blank
/// line, the whole thing followed by a blank line, and the empty string when
/// there are no blocks at all.
#[must_use]
pub(crate) fn format_turn_context(context: &AssistantTurnContext) -> String {
    let mut blocks: Vec<String> = Vec::new();
    if let Some(url) = context.url.as_deref() {
        blocks.push(if context.concepts.is_empty() {
            format!("On screen: {url}")
        } else {
            format!("On screen: {url}\nShowing: {}", context.concepts.join(", "))
        });
    }
    if let Some(document) = context.document.as_ref() {
        blocks.push(format_document_block(document));
    }
    if blocks.is_empty() {
        return String::new();
    }
    format!("{}\n\n", blocks.join("\n\n"))
}

/// Whether this context would put anything at all in front of the prompt.
#[must_use]
pub(crate) fn is_empty(context: &AssistantTurnContext) -> bool {
    context.url.is_none() && context.concepts.is_empty() && context.document.is_none()
}

/// The document block: its path, its text fenced as AWL, and the selection —
/// or the caret and the explicit statement that there is no selection.
fn format_document_block(document: &AssistantDocumentContext) -> String {
    let fenced = format!(
        "Document: {}\n```awl\n{}\n```",
        document.path, document.text
    );
    match (document.selection.as_ref(), document.cursor.as_ref()) {
        (Some(selection), _) => format!("{fenced}\n{}", describe_selection(selection)),
        // The caret is where the operator IS; it travels in the context, not
        // stapled to their words. A selection already says where they are.
        (None, Some(cursor)) => {
            format!("{fenced}\n{}\n{NOTHING_SELECTED}", describe_cursor(*cursor))
        }
        // STATED, not omitted: an agent that is handed a whole document with no
        // word about selection cannot tell "the operator selected nothing" from
        // "the selection was lost on the way here", and the two call for
        // different behaviour.
        (None, None) => format!("{fenced}\n{NOTHING_SELECTED}"),
    }
}

/// The console's own sentence for a document with no selection.
const NOTHING_SELECTED: &str = "Nothing is selected — the whole document is offered.";

/// The caret, in the one-based numbers an operator reads off a gutter.
fn describe_cursor(cursor: AssistantDocumentPosition) -> String {
    format!(
        "Cursor: line {}, column {}.",
        cursor.line.saturating_add(1),
        cursor.column.saturating_add(1)
    )
}

/// The selected region, in the one-based numbers an operator reads off a gutter.
fn describe_selection(selection: &AssistantDocumentSelection) -> String {
    format!(
        "Selected: line {}, column {} through line {}, column {}.",
        selection.from.line.saturating_add(1),
        selection.from.column.saturating_add(1),
        selection.to.line.saturating_add(1),
        selection.to.column.saturating_add(1)
    )
}

#[cfg(test)]
#[path = "prompt_tests.rs"]
mod tests;