Rules and conventions for AI assistants and contributors working on aionrs.
aionrs is a multi-provider AI agent CLI written in Rust. It connects to LLM providers (Anthropic, OpenAI, AWS Bedrock, Google Vertex AI), orchestrates built-in tools (Read, Write, Edit, ExecCommand, Grep, Glob, ViewImage, Spawn), supports MCP servers, skills, hooks, and long-term memory. It also exposes a JSON stream protocol for host integration (e.g. Electron-based AionUI).
Tech stack: Rust 2021 edition, stable toolchain, Cargo workspace under crates/.
Dependencies flow downward — never introduce circular or upward references.
| Layer | Crate | Responsibility |
|---|---|---|
| Bottom | aion-types |
Shared provider-neutral data types (LLM, message, tool) — zero internal deps |
| Bottom | aion-compact |
Context compression algorithms (folding, sanitization, tokenization) |
| Mid | aion-config |
Configuration, ProviderCompat, auth, hooks, logging (create_file_layer), cross-platform shell helpers |
| Mid | aion-protocol |
JSON stream protocol (events, commands, approval manager) for host integration |
| Mid | aion-providers |
LLM provider implementations (Anthropic, OpenAI, Bedrock, Vertex) |
| Mid | aion-tools |
Local tools (Read, Write, Edit, ExecCommand, Grep, Glob, ViewImage, ToolSearch) |
| Mid | aion-mcp |
MCP (Model Context Protocol) client |
| Mid | aion-skills |
Skills system (prompt snippets, hooks, permissions, shell expansion) |
| Mid | aion-memory |
Long-term cross-session memory (user prefs, feedback, project context) |
| Top | aion-agent |
Agent engine, session management, orchestration, and agent-level tools such as Spawn |
| Top | aion-cli |
CLI binary entry point |
When adding new functionality, place it in the lowest crate where it
semantically belongs. Don't create a new crate just for one shared function.
Run cargo metadata to verify dependency changes fit the graph.
cargo build # Build
cargo test # Run all tests
cargo clippy # Lint
cargo fmt --all # Format (CI enforces this)Pushing code: always use just push instead of git push.
It runs fmt → clippy → test before pushing, preventing CI failures.
Supports the same arguments as git push (e.g. just push -u origin branch).
After implementation and before staging or committing, perform a self-check against this section. Do not rely on tests or review to catch style drift.
cargo clippymust pass without warningscargo fmtmust pass without diffs- Comments in English, commit messages in English
- Error handling:
thiserrorfor public API error types (structured, matchable)anyhowfor internal/application-level error propagation- Never silently swallow errors; never
unwrap()in production code unless the invariant is proven and commented
- After finishing a task, explicitly review visibility for any new or modified functions, types, modules, constants, configuration items, events, protocol entries, or APIs. Confirm they really need to be externally exposed.
- Items that are not used across modules, crates, processes, or packages should remain private or file/module-scoped. Do not expose them just for convenience.
- When exposure is necessary, choose the narrowest suitable scope. In Rust,
prefer restricted visibility such as
pub(crate),pub(super), orpub(in ...)over barepub. - Public interfaces must have clear callers, stability expectations, and boundary semantics. If an export exists only for tests, temporary wiring, or bypassing a module boundary, redesign it or use a narrower entry point.
- Do not write business logic in
mod.rsorlib.rs. These files are only for module visibility declarations and re-exports. - Structs with many fields should be grouped by responsibility first. Keep related runtime state, configuration, and dependencies together instead of ordering fields by when they were added or temporarily used.
- Prefer importing any type, function, trait, constant, or value path that is
outside the current
modwithuse, then reference it by local name. If a usage path contains two or more::segments (for example,session::SessionManager::neworpre_message::PreMessageOutcome::Stop), import it so the use site has at most one::. - The import rule applies to type positions, function calls, value paths, and
turbofish syntax. Conventional exceptions may remain qualified: macros such
as
tracing::info!,tokio::select!, andanyhow::bail!; attribute macros such as#[tokio::main];anyhow::Resultwhen needed to avoid conflicts with standard names; and standard module idioms such asenv::current_dirorio::stdin. - Rust test submodules must be extracted into same-directory
*_test.rsfiles. The source file should keep only the test mount, for example:#[cfg(test)]plus#[path = "..._test.rs"] mod ..._test;.
- AionCLI is the built-in SDK/library integration path. Do not model it as an external agent subprocess when changing Aion host integration code.
- For Claude Code, Codex, or other external agent integrations, first review the ACP protocol, subprocess lifecycle, logging, and security boundaries.
When changing a critical path, explicitly evaluate whether logs are needed for development diagnosis and production troubleshooting. Add structured logs with appropriate levels:
debugfor detailed, high-frequency internal flow that helps verify behavior and diagnose issues in developmentinfofor low-volume lifecycle boundaries useful in productionwarnfor malformed or unexpected data that is safely handlederrorfor contract violations or failed operations
Production-visible logs must not include sensitive payloads such as prompts, tool input/output, file contents, command bodies, tokens, secrets, or raw provider requests/responses. If such payloads are needed for local debugging, they must be behind explicit development-only guards and never enabled by default.
- Each module (
.rsfile) follows the single responsibility principle — one clear purpose per file - Keep files under 1000 lines; extract sub-modules when approaching the limit
- Organize by domain responsibility, not by type
This is the single most important rule for this codebase.
Handle provider differences through the ProviderCompat configuration
layer, not through hardcoded conditionals.
// WRONG: hardcoded provider detection
if self.base_url.contains("api.openai.com") {
body["max_completion_tokens"] = json!(max_tokens);
}
// CORRECT: read from compat config
let field = self.compat.max_tokens_field.as_deref().unwrap_or("max_tokens");
body[field] = json!(request.max_tokens);If you need a new compat behavior:
- Add an
Option<T>field toProviderCompat - Set its default in the appropriate preset function (e.g.
openai_defaults()) - Use it in provider code via
self.compat.field_name
All providers implement the LlmProvider trait. The engine sees only
provider-neutral types (LlmRequest, LlmEvent, Message, ContentBlock).
Format conversion happens inside each provider's build_messages() /
build_request_body().
Deep dive: see docs/providers.md for provider setup, auth, aliases, and profile inheritance.
Any platform-specific behavior (paths, permissions, shell commands, line endings, etc.) must be wrapped in a single centralized function. All call sites use that function — never scatter raw platform detection across multiple crates or modules. See Cross-Platform for concrete rules.
If multiple crates need the same functionality, extract it to the appropriate existing crate in the dependency graph — don't copy-paste or reimplement. Choose the extraction target based on where it semantically belongs and where it minimizes dependency changes.
CI runs on macOS, Linux, and Windows. Local dev can only test the
current platform's #[cfg(...)] code — other platform branches are
verified by CI alone.
- Never hardcode platform paths (
/tmp/...,C:\...) in production code. UsePath::join(),dirs::config_dir(),tempfile::tempdir(), etc. - In tests, hardcoded Unix paths (
Path::new("/foo/...")) are fine for pure string operations (join, display) or nonexistent-path error handling. Only add#[cfg(unix)]/#[cfg(windows)]variants when the path is passed tois_absolute(),validate_memory_path(), or similar platform-sensitive checks. - Use
std::path::Component::Normal(not byte length) when checking path depth — prefix/root components differ across platforms.
- All shell invocations must go through
aion_config::shellmodule (shell_command()orshell_command_builder()). - Never call
Command::new("sh"),Command::new("bash"), orCommand::new("cmd")directly — these are platform-specific. - External CLI tools that differ across platforms (e.g.
grepvsfindstr) must usecfg!(windows)branches or equivalent platform-aware selection.
| Location | What goes there |
|---|---|
Same-directory *_test.rs mounted from each .rs file with #[cfg(test)] + #[path = "..._test.rs"] mod ..._test; |
Unit tests for that module's internals |
crates/<crate>/tests/ |
Integration tests for that crate |
Unit tests target internal logic and code paths. Integration tests target functional requirements and public API — write them from the spec, not from reading the implementation.
Every test must verify a meaningful behavior or edge case. No trivial tests that just assert the happy path without checking boundaries, error conditions, or non-obvious logic.
Key references in docs/ (don't duplicate their content here):
| Document | Covers |
|---|---|
| getting-started.md | Installation, CLI usage, config format and cascading precedence |
| providers.md | Provider setup, auth, ProviderCompat, custom aliases, profiles |
| tools.md | Built-in tool reference and execution flow |
| skills.md | Writing skills, front matter, shell expansion, conditional activation |
| mcp.md | MCP server integration, transport types, deferred loading |
| advanced.md | Sub-agents, hooks, logging, memory, plan mode, context compression |
| json-stream-protocol.md | JSON Lines protocol spec for host integration (e.g. AionUI) |
| troubleshooting.md | Common errors and solutions |