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
//! `assistant_document_edit` — the tool that edits the operator's document.
//!
//! The agent submits replace-exactly-once operations against the shared
//! document — the same buffer `assistant_context` answers with. The whole
//! batch is validated and recorded by
//! `sessions/document_edits.rs`; the recorded event streams to the console,
//! where the editor applies the operations live and the operator keeps or
//! reverts them. Nothing here touches a file: the document being edited is the
//! operator's BUFFER, which may never have been saved anywhere.

use aion_core::AssistantDocumentEditOp;
use aion_mcp::tools::service::{ToolFailure, ToolOutcome};
use serde::Deserialize;
use serde_json::{Value, json};

use crate::assistant::sessions::AssistantSessions;
use crate::assistant::sessions::document_edits::DocumentEditRefusal;

use super::caller::AssistantSessionCaller;

/// The tool's arguments, exactly as the published schema admits them.
#[derive(Deserialize)]
struct EditArguments {
    /// The operations, in application order.
    edits: Vec<AssistantDocumentEditOp>,
}

/// Answer `assistant_document_edit` for the session the caller speaks for.
///
/// # Errors
///
/// [`ToolFailure`] phrased for the model that reads it: malformed arguments,
/// an empty batch, no shared document, a batch that does not apply, or a
/// transcript that could not be written. On every failure nothing was recorded
/// and the operator's document is untouched.
pub(crate) async fn assistant_document_edit(
    sessions: &AssistantSessions,
    caller: &AssistantSessionCaller,
    arguments: Value,
) -> Result<ToolOutcome, ToolFailure> {
    let arguments: EditArguments = serde_json::from_value(arguments).map_err(|error| {
        ToolFailure::new(
            format!(
                "the arguments do not match the published shape — expected \
                 {{\"edits\": [{{\"old_string\": …, \"new_string\": …}}]}}: {error}"
            ),
            json!({ "code": "invalid_arguments" }),
        )
    })?;
    if arguments.edits.is_empty() {
        return Err(ToolFailure::new(
            "the batch is empty — send at least one edit, or send none at all.".to_owned(),
            json!({ "code": "empty_batch" }),
        ));
    }
    let receipt = sessions
        .record_document_edit(caller.session_id(), arguments.edits)
        .await
        .map_err(|refusal| match refusal {
            DocumentEditRefusal::NoDocument => ToolFailure::new(
                "there is no document to edit: the operator has not shared one from an editor. \
                 This does NOT mean their screen is empty — call `assistant_context` to see what \
                 they have shared."
                    .to_owned(),
                json!({ "code": "no_document" }),
            ),
            DocumentEditRefusal::Edits(error) => ToolFailure::new(
                format!(
                    "the batch does not apply to the document as it now stands, and NONE of it \
                     was applied: {error}"
                ),
                json!({ "code": "edit_rejected" }),
            ),
            DocumentEditRefusal::Session(error) => ToolFailure::new(
                format!(
                    "the edit could not be recorded for this conversation: {error}. The document \
                     is untouched."
                ),
                json!({ "code": "transcript_unwritable" }),
            ),
        })?;
    Ok(ToolOutcome {
        structured: json!({
            "applied": receipt.applied,
            "revision": receipt.revision,
            "path": receipt.path,
        }),
        summary: format!(
            "Applied {} edit{} to `{}` — the operator sees them in their editor now and decides \
             whether to keep them. The shared document is at revision {}.",
            receipt.applied,
            if receipt.applied == 1 { "" } else { "s" },
            receipt.path,
            receipt.revision,
        ),
    })
}