aion-server 0.29.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! The assistant-only catalogue: three tools, about one conversation.
//!
//! Together they are the whole editing loop: `assistant_context` reads what
//! the operator has on screen, `assistant_document_edit` changes the document
//! they are editing, and `assistant_document_check` runs the editor's own AWL
//! check over the result. Every one of them is addressed by SESSION — the
//! credential names the conversation — which is why they live on this route
//! and not the general catalogue.

use aion_mcp::tools::descriptor::{
    Destructiveness, Idempotence, Tool, ToolAnnotations, WorldScope,
};
use aion_mcp::tools::service::{CatalogError, ToolCatalog};
use serde_json::json;

/// The read: what is on the operator's screen.
pub const ASSISTANT_CONTEXT_TOOL: &str = "assistant_context";
/// The write: replace-exactly-once edits to the shared document.
pub const ASSISTANT_DOCUMENT_EDIT_TOOL: &str = "assistant_document_edit";
/// The gate: the editor's own AWL check over the shared document.
pub const ASSISTANT_DOCUMENT_CHECK_TOOL: &str = "assistant_document_check";

/// Every session tool, in catalogue order — the ONE list the catalogue, the
/// dispatcher's census pins, and the harness descriptor all read, so a tool
/// added to the loop cannot be published in one place and missing from
/// another.
pub(crate) const SESSION_TOOL_NAMES: [&str; 3] = [
    ASSISTANT_CONTEXT_TOOL,
    ASSISTANT_DOCUMENT_EDIT_TOOL,
    ASSISTANT_DOCUMENT_CHECK_TOOL,
];

/// Build the assistant catalogue.
///
/// # Errors
///
/// [`CatalogError`] when a published schema fails to compile — a server
/// defect, surfaced at startup rather than on an agent's first call.
pub(crate) fn assistant_tool_catalog() -> Result<ToolCatalog, CatalogError> {
    ToolCatalog::new(vec![
        assistant_context(),
        assistant_document_edit(),
        assistant_document_check(),
    ])
}

/// `assistant_context` — what is on the operator's screen right now.
fn assistant_context() -> Tool {
    Tool {
        name: ASSISTANT_CONTEXT_TOOL.to_owned(),
        title: Some("Read what the operator is looking at".to_owned()),
        description: Some(
            "Read what the operator has on screen in the Aion console right now: the page they \
             are on, the concepts that page explains, and the document their editor is showing \
             with any selection inside it and where their caret is. Call this BEFORE asking the operator where anything \
             is — the document's path comes back with it, so you never need to ask which \
             directory a repository is in or which file is open. It answers for THIS \
             conversation only and takes no arguments: there is no session to name, because the \
             credential you called with already names one."
                .to_owned(),
        ),
        // Closed on purpose: no argument at all, so there is nothing to name a
        // session with. A session id argument would be a way to ask about
        // somebody else's conversation, and the safest argument is the one that
        // does not exist.
        input_schema: json!({
            "type": "object",
            "properties": {},
            "required": [],
            "additionalProperties": false,
        }),
        output_schema: Some(assistant_context_output_schema()),
        annotations: ToolAnnotations::read_only(WorldScope::Closed),
    }
}

/// `assistant_document_edit` — edit the document the operator is editing.
/// What `assistant_context` answers with, as the schema the harness reads.
///
/// Its own function because the shape is the tool's whole contract — every
/// field an operator can have on screen, described — and it outgrew the
/// constructor it sat in.
fn assistant_context_output_schema() -> serde_json::Value {
    json!({
        "type": "object",
        "properties": {
            "shared": {
                "type": "boolean",
                "description":
                    "False when the operator has shared nothing yet, in which case every \
                     other field is absent. Not an error and not an empty screen: it means \
                     nobody has told this server what is on it.",
            },
            "url": {
                "type": ["string", "null"],
                "description": "The console route the operator is on, or null.",
            },
            "concepts": {
                "type": "array",
                "items": { "type": "string" },
                "description":
                    "The titles of the explain concepts that screen declares — what the \
                     surface itself says it is about.",
            },
            "document": {
                "type": ["object", "null"],
                "description":
                    "The document the operator's editor is showing, or null when they are \
                     not in an editor.",
                "properties": {
                    "path": {
                        "type": "string",
                        "description":
                            "The document's path, as the console holds it. This is the \
                             answer to `which file` — do not ask.",
                    },
                    "text": {
                        "type": "string",
                        "description":
                            "The document's current text, INCLUDING unsaved edits. It may \
                             differ from what is on disk; this is what the operator is \
                             looking at.",
                    },
                    "selection": {
                        "type": ["object", "null"],
                        "description":
                            "The selected range, or null when nothing is selected and the \
                             whole document is offered. Lines and columns are ONE-based \
                             here, matching what the operator reads off the gutter.",
                        "properties": {
                            "from_line": { "type": "integer", "minimum": 1 },
                            "from_column": { "type": "integer", "minimum": 1 },
                            "to_line": { "type": "integer", "minimum": 1 },
                            "to_column": { "type": "integer", "minimum": 1 },
                        },
                        "required": ["from_line", "from_column", "to_line", "to_column"],
                        "additionalProperties": false,
                    },
                    "cursor": {
                        "type": ["object", "null"],
                        "description":
                            "Where the operator's caret is, ONE-based like the selection, or \
                             null when the document did not come from a live editor. With no \
                             selection this is the line the operator means by `here`.",
                        "properties": {
                            "line": { "type": "integer", "minimum": 1 },
                            "column": { "type": "integer", "minimum": 1 },
                        },
                        "required": ["line", "column"],
                        "additionalProperties": false,
                    },
                },
                "required": ["path", "text"],
                "additionalProperties": false,
            },
            "revision": {
                "type": "integer",
                "description":
                    "The shared document's revision: how many edit batches have been \
                     recorded for this conversation. Compare it with the revision your \
                     last `assistant_document_edit` returned — a larger jump than your own \
                     edits explain means the document moved under you, so re-read before \
                     quoting bytes from an older read.",
            },
        },
        "required": ["shared"],
        "additionalProperties": false,
    })
}

fn assistant_document_edit() -> Tool {
    Tool {
        name: ASSISTANT_DOCUMENT_EDIT_TOOL.to_owned(),
        title: Some("Edit the operator's document".to_owned()),
        description: Some(
            "Edit the document the operator is editing, in place. Submit one or more edits, each \
             quoting the EXACT bytes to replace (`old_string`, which must occur exactly once in \
             the document as it now stands) and what replaces them (`new_string`). The batch is \
             atomic: if any edit does not apply, none of it does, and the failure says which edit \
             and why. Applied edits appear in the operator's editor immediately — they watch each \
             change land and choose to keep or revert it, so make SMALL, named changes one \
             concept at a time rather than rewriting the whole document. Read the document with \
             `assistant_context` first and quote from what it returned; after editing, run \
             `assistant_document_check` and fix what it reports before telling the operator what \
             you changed. This edits the operator's BUFFER — never read or write files to reach \
             this document, and never save it: saving stays the operator's act. Edit only when \
             the operator asked for a change: a question gets an answer in a sentence or two and \
             no edit. When you have changed something, say what in a sentence or two — never \
             paste the document back into your reply; the operator is looking at it."
                .to_owned(),
        ),
        input_schema: json!({
            "type": "object",
            "properties": {
                "edits": {
                    "type": "array",
                    "minItems": 1,
                    "description": "The operations, applied in order, each against the text the \
                                    previous one produced.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "old_string": {
                                "type": "string",
                                "description": "The exact bytes to replace. Must occur exactly \
                                                once; quote more surrounding text to make an \
                                                ambiguous match unique.",
                            },
                            "new_string": {
                                "type": "string",
                                "description": "What replaces them.",
                            },
                        },
                        "required": ["old_string", "new_string"],
                        "additionalProperties": false,
                    },
                },
            },
            "required": ["edits"],
            "additionalProperties": false,
        }),
        output_schema: Some(json!({
            "type": "object",
            "properties": {
                "applied": {
                    "type": "integer",
                    "description": "How many edits the batch carried — all of them applied.",
                },
                "revision": {
                    "type": "integer",
                    "description": "The shared document's revision after this batch.",
                },
                "path": {
                    "type": "string",
                    "description": "The document that was edited, by the path the console shared.",
                },
            },
            "required": ["applied", "revision", "path"],
            "additionalProperties": false,
        })),
        // Mutating and destructive: it overwrites bytes the operator may not
        // have saved anywhere else. Repeating: the same batch submitted twice
        // is refused the second time (its `old_string`s are gone), which is a
        // refusal, not a no-op.
        annotations: ToolAnnotations::mutating(
            Destructiveness::Destructive,
            Idempotence::Repeating,
            WorldScope::Closed,
        ),
    }
}

/// `assistant_document_check` — the editor's own AWL check, as a tool.
fn assistant_document_check() -> Tool {
    Tool {
        name: ASSISTANT_DOCUMENT_CHECK_TOOL.to_owned(),
        title: Some("Check the operator's document".to_owned()),
        description: Some(
            "Run the AWL checker over the document the operator is editing, as it now stands — \
             your own edits included. This is the SAME check the operator's editor runs, with \
             the same workspace — verdicts differ only when the operator's own typing has \
             diverged their buffer from the shared document, and their bytes win. It takes no \
             arguments: \
             the document is the one your credential's conversation shares. Check after every \
             edit and fix what it reports with `assistant_document_edit` BEFORE telling the \
             operator what you changed — an edit you have not checked is work you have not \
             finished."
                .to_owned(),
        ),
        input_schema: json!({
            "type": "object",
            "properties": {},
            "required": [],
            "additionalProperties": false,
        }),
        output_schema: Some(json!({
            "type": "object",
            "properties": {
                "ok": {
                    "type": "boolean",
                    "description": "True when the document would deploy: no diagnostics.",
                },
                "diagnostics": {
                    "type": "array",
                    "description": "Every diagnostic, in document order. Lines and columns are \
                                    the numbers the operator reads off their gutter.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "line": { "type": "integer" },
                            "column": { "type": "integer" },
                            "message": { "type": "string" },
                        },
                        "required": ["line", "column", "message"],
                        "additionalProperties": false,
                    },
                },
            },
            "required": ["ok", "diagnostics"],
            "additionalProperties": false,
        })),
        annotations: ToolAnnotations::read_only(WorldScope::Closed),
    }
}

#[cfg(test)]
mod tests {
    use aion_mcp::tools::descriptor::Modification;

    use super::{
        ASSISTANT_CONTEXT_TOOL, ASSISTANT_DOCUMENT_CHECK_TOOL, ASSISTANT_DOCUMENT_EDIT_TOOL,
        SESSION_TOOL_NAMES, assistant_tool_catalog,
    };
    use crate::mcp::catalog::aion_tool_catalog;

    /// The whole catalogue is these three tools, in this order. A tool added
    /// here without a dispatch arm would be a published name nothing serves —
    /// and one added in the dispatcher without a row here would serve a name
    /// nothing lists.
    #[test]
    fn the_assistant_catalogue_publishes_exactly_the_editing_loop()
    -> Result<(), Box<dyn std::error::Error>> {
        let catalog = assistant_tool_catalog()?;
        let names: Vec<&str> = catalog
            .tools()
            .iter()
            .map(|tool| tool.name.as_str())
            .collect();
        assert_eq!(names, SESSION_TOOL_NAMES);
        Ok(())
    }

    /// THE separation pin. The general catalogue must never gain these names:
    /// every session tool answers for one conversation and is authorized by
    /// one session's bearer, and a general caller reaching one would be
    /// reading — or editing — somebody else's screen.
    ///
    /// Asserted by NAME against the real general catalogue, so adding a tool
    /// there fails here rather than in review.
    #[test]
    fn the_general_catalogue_does_not_publish_the_session_tools()
    -> Result<(), Box<dyn std::error::Error>> {
        let general = aion_tool_catalog()?;
        for name in SESSION_TOOL_NAMES {
            assert!(
                general.find(name).is_none(),
                "`{name}` must not be in the general catalogue: it is addressed by session, and \
                 the general surface is addressed by namespace"
            );
        }
        // The positive control: the general catalogue is not simply empty.
        assert!(
            general.find("describe_run").is_some(),
            "the general catalogue is empty, so the assertion above measures nothing"
        );
        Ok(())
    }

    /// It reads and changes nothing, and it takes NO arguments — there is no
    /// session to name, so there is no argument through which one conversation
    /// could ask about another.
    #[test]
    fn the_context_tool_is_read_only_and_names_no_session() -> Result<(), Box<dyn std::error::Error>>
    {
        let catalog = assistant_tool_catalog()?;
        let tool = catalog
            .find(ASSISTANT_CONTEXT_TOOL)
            .ok_or("the catalogue must publish the context tool")?;
        assert_eq!(tool.annotations.modification, Modification::ReadOnly);
        assert_eq!(
            tool.input_schema["properties"],
            serde_json::json!({}),
            "an argument here would be a way to ask about another session"
        );
        assert_eq!(tool.input_schema["additionalProperties"], false);
        assert!(tool.output_schema.is_some());
        assert!(tool.description.is_some());
        Ok(())
    }

    /// The edit tool is mutating and destructive ON PURPOSE — it overwrites
    /// bytes the operator may not have saved anywhere — and its schema admits
    /// exactly `edits: [{old_string, new_string}]`, closed at every level, so
    /// an argument for naming a session or a file cannot grow in quietly.
    #[test]
    fn the_edit_tool_is_mutating_and_admits_only_edit_pairs()
    -> Result<(), Box<dyn std::error::Error>> {
        let catalog = assistant_tool_catalog()?;
        let tool = catalog
            .find(ASSISTANT_DOCUMENT_EDIT_TOOL)
            .ok_or("the catalogue must publish the edit tool")?;
        assert_eq!(tool.annotations.modification, Modification::Mutating);
        assert_eq!(tool.input_schema["required"], serde_json::json!(["edits"]));
        assert_eq!(tool.input_schema["additionalProperties"], false);
        let items = &tool.input_schema["properties"]["edits"]["items"];
        assert_eq!(
            items["required"],
            serde_json::json!(["old_string", "new_string"])
        );
        assert_eq!(items["additionalProperties"], false);
        assert!(tool.output_schema.is_some());
        Ok(())
    }

    /// The check tool reads and changes nothing, and — like the context tool —
    /// takes NO arguments: the document it checks is named by the credential's
    /// conversation, so there is nothing through which to check another.
    #[test]
    fn the_check_tool_is_read_only_and_names_no_document() -> Result<(), Box<dyn std::error::Error>>
    {
        let catalog = assistant_tool_catalog()?;
        let tool = catalog
            .find(ASSISTANT_DOCUMENT_CHECK_TOOL)
            .ok_or("the catalogue must publish the check tool")?;
        assert_eq!(tool.annotations.modification, Modification::ReadOnly);
        assert_eq!(tool.input_schema["properties"], serde_json::json!({}));
        assert_eq!(tool.input_schema["additionalProperties"], false);
        assert!(tool.output_schema.is_some());
        assert!(tool.description.is_some());
        Ok(())
    }
}