#[cfg(feature = "telegram")]
pub async fn telegram() -> anyhow::Result<()> {
imp::telegram().await
}
#[cfg(not(feature = "telegram"))]
pub async fn telegram() -> anyhow::Result<()> {
anyhow::bail!(
"this build has no Telegram support — reinstall with \
`cargo install aonyx-agent --features telegram`, or grab a release binary"
)
}
#[cfg(feature = "discord")]
pub async fn discord() -> anyhow::Result<()> {
imp::discord().await
}
#[cfg(not(feature = "discord"))]
pub async fn discord() -> anyhow::Result<()> {
anyhow::bail!(
"this build has no Discord support — reinstall with \
`cargo install aonyx-agent --features discord`, or grab a release binary"
)
}
#[cfg(feature = "openai-server")]
pub async fn openai(port: u16, token: Option<String>) -> anyhow::Result<()> {
imp::openai(port, token).await
}
#[cfg(not(feature = "openai-server"))]
pub async fn openai(_port: u16, _token: Option<String>) -> anyhow::Result<()> {
anyhow::bail!(
"this build has no OpenAI-server support — reinstall with \
`cargo install aonyx-agent --features openai-server`, or grab a release binary"
)
}
#[cfg(feature = "api")]
pub async fn api(port: u16, token: Option<String>, bind: String) -> anyhow::Result<()> {
api_imp::run(port, token, bind).await
}
#[cfg(not(feature = "api"))]
pub async fn api(_port: u16, _token: Option<String>, _bind: String) -> anyhow::Result<()> {
anyhow::bail!(
"this build has no API support — reinstall with \
`cargo install aonyx-agent --features api`, or grab a release binary"
)
}
#[cfg(feature = "api")]
mod api_imp {
use std::sync::Arc;
use aonyx_agent::{AgentRunner, ApprovalPolicy, TurnEvent};
use aonyx_api::{
ApiAgent, ApiState, AuthConfig, ConfigInfo, ServerInfo, SkillInfo, StreamFrame, ToolInfo,
};
use aonyx_core::{Message, SafetyClass};
use aonyx_memory::{Palace, SqliteSessionStore};
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::config::Config;
struct ApiRunner {
runner: AgentRunner,
tools: Vec<ToolInfo>,
skills: Vec<SkillInfo>,
config: ConfigInfo,
}
#[async_trait]
impl ApiAgent for ApiRunner {
async fn run_turn(&self, history: Vec<Message>) -> aonyx_core::Result<Vec<Message>> {
Ok(self.runner.run(history).await?.messages)
}
async fn run_turn_streaming(
&self,
history: Vec<Message>,
tx: mpsc::Sender<StreamFrame>,
) -> aonyx_core::Result<Vec<Message>> {
let (etx, mut erx) = mpsc::channel::<TurnEvent>(128);
let forward = async move {
while let Some(ev) = erx.recv().await {
if let Some(frame) = map_event(ev) {
if tx.send(frame).await.is_err() {
break;
}
}
}
};
let drive = self.runner.run_streaming(history, etx);
let (res, _) = tokio::join!(drive, forward);
Ok(res?.messages)
}
fn tools(&self) -> Vec<ToolInfo> {
self.tools.clone()
}
fn skills(&self) -> Vec<SkillInfo> {
self.skills.clone()
}
fn config(&self) -> ConfigInfo {
self.config.clone()
}
}
fn class_str(c: SafetyClass) -> String {
match c {
SafetyClass::Safe => "safe",
SafetyClass::Caution => "caution",
SafetyClass::Destructive => "destructive",
}
.to_string()
}
fn map_event(ev: TurnEvent) -> Option<StreamFrame> {
Some(match ev {
TurnEvent::AssistantDelta(text) => StreamFrame::Delta { text },
TurnEvent::ToolStart { name, args, class } => StreamFrame::ToolStart {
name,
args,
class: class_str(class),
},
TurnEvent::ToolEnd { name, ok, summary } => StreamFrame::ToolEnd { name, ok, summary },
TurnEvent::ToolRejected { name, class } => StreamFrame::ToolRejected {
name,
class: class_str(class),
},
TurnEvent::IterationStart(n) => StreamFrame::Iteration { n: n as u32 },
TurnEvent::AssistantMessageEnd | TurnEvent::Done { .. } => return None,
})
}
fn tool_infos(registry: &aonyx_tools::ToolRegistry) -> Vec<ToolInfo> {
let names: Vec<String> = registry.names().map(|s| s.to_string()).collect();
names
.into_iter()
.filter_map(|name| registry.get(&name))
.map(|h| {
let schema = h.schema();
let description = schema
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
ToolInfo {
name: h.name().to_string(),
description,
class: class_str(h.classify()),
schema,
}
})
.collect()
}
fn skill_infos(skills: &[aonyx_skills::Skill]) -> Vec<SkillInfo> {
skills
.iter()
.map(|s| {
let mut triggers = Vec::new();
if s.trigger.always_on {
triggers.push("always-on".to_string());
}
if s.trigger.manual {
triggers.push("manual".to_string());
}
triggers.extend(s.trigger.keywords.iter().cloned());
if let Some(p) = &s.trigger.project_matches {
triggers.push(format!("project~/{p}/"));
}
SkillInfo {
id: s.id.clone(),
description: s.name.clone(),
triggers,
}
})
.collect()
}
fn api_features() -> Vec<String> {
vec![
"memory".to_string(),
"openai-compat".to_string(),
"sessions".to_string(),
"streaming".to_string(),
"tools".to_string(),
]
}
pub async fn run(port: u16, token: Option<String>, bind: String) -> anyhow::Result<()> {
let config = Config::load_or_init()?;
let cwd = std::env::current_dir()?;
let project = crate::project_slug(&cwd);
let provider = crate::build_provider(&config)?;
let registry = crate::build_serve_registry().await?;
let skills = crate::load_all_skills();
let tools = tool_infos(®istry);
let skill_list = skill_infos(&skills);
let config_info = ConfigInfo {
provider: config.provider.clone(),
model: config.model.clone(),
max_iterations: config.max_iterations,
skill_autogen: config.skill_autogen,
};
let runner = AgentRunner::new(provider, registry, config.model.clone())
.with_max_iterations(config.max_iterations)
.with_approval(ApprovalPolicy::DenyDestructive)
.with_skills(skills)
.with_project(&project);
let api_runner = ApiRunner {
runner,
tools,
skills: skill_list,
config: config_info,
};
let palace = Palace::open(Palace::default_project_dir(&cwd))?;
let sessions = SqliteSessionStore::open(Config::config_dir()?.join("sessions.db"))?;
let token = token
.or_else(|| crate::secrets::get("api_token"))
.or_else(|| std::env::var("AONYX_API_TOKEN").ok());
let loopback = matches!(bind.as_str(), "127.0.0.1" | "::1" | "localhost");
if !loopback && token.is_none() {
anyhow::bail!(
"refusing to bind {bind} without a token — pass --token, set \
AONYX_API_TOKEN, run `aonyx setup`-stored `api_token`, or bind 127.0.0.1"
);
}
let info = ServerInfo::new(
config.provider.clone(),
config.model.clone(),
api_features(),
);
let auth = AuthConfig::new(token.clone(), false);
let state = ApiState::new(
auth,
info,
Arc::new(sessions),
Arc::new(palace),
Arc::new(api_runner),
project,
);
let addr = format!("{bind}:{port}");
if token.is_some() {
eprintln!(
"aonyx: API on http://{addr}/v1 (bearer auth ON) — \
docs at /v1/openapi.json. Ctrl-C to stop."
);
} else {
eprintln!(
"aonyx: API on http://{addr}/v1 (no auth — keep it on localhost) — \
docs at /v1/openapi.json. Ctrl-C to stop."
);
}
aonyx_api::serve(state, &addr)
.await
.map_err(|e| anyhow::anyhow!("api serve: {e}"))
}
}
#[cfg(any(feature = "telegram", feature = "discord", feature = "openai-server"))]
mod imp {
use std::collections::HashMap;
use std::sync::Arc;
use aonyx_adapters::{AgentHandler, StreamEvent};
use aonyx_agent::{AgentRunner, TurnEvent};
use aonyx_core::{Message, Role};
use async_trait::async_trait;
use tokio::sync::{mpsc, Mutex};
use crate::config::Config;
const MAX_HISTORY: usize = 40;
struct RunnerHandler {
runner: AgentRunner,
system_prompt: Option<String>,
chats: Mutex<HashMap<String, Vec<Message>>>,
}
impl RunnerHandler {
fn seed(&self) -> Vec<Message> {
match &self.system_prompt {
Some(p) => vec![Message::new(Role::System, p.clone())],
None => Vec::new(),
}
}
}
#[async_trait]
impl AgentHandler for RunnerHandler {
async fn handle(&self, chat_id: &str, text: &str) -> aonyx_core::Result<String> {
let mut history = {
let map = self.chats.lock().await;
map.get(chat_id).cloned().unwrap_or_else(|| self.seed())
};
history.push(Message::new(Role::User, text));
let result = self.runner.run(history).await?;
let reply = last_assistant_text(&result.messages);
let trimmed = trim_history(result.messages, MAX_HISTORY);
self.chats.lock().await.insert(chat_id.to_string(), trimmed);
Ok(reply)
}
async fn complete(&self, messages: Vec<(String, String)>) -> aonyx_core::Result<String> {
let msgs: Vec<Message> = messages
.into_iter()
.map(|(role, content)| Message::new(role_from_str(&role), content))
.collect();
let result = self.runner.run(msgs).await?;
Ok(last_assistant_text(&result.messages))
}
async fn handle_stream(
&self,
chat_id: &str,
text: &str,
out: mpsc::Sender<StreamEvent>,
) -> aonyx_core::Result<()> {
let mut history = {
let map = self.chats.lock().await;
map.get(chat_id).cloned().unwrap_or_else(|| self.seed())
};
history.push(Message::new(Role::User, text));
let (etx, mut erx) = mpsc::channel::<TurnEvent>(256);
let fwd = out.clone();
let forward = async move {
while let Some(ev) = erx.recv().await {
let mapped = match ev {
TurnEvent::AssistantDelta(t) => Some(StreamEvent::Delta(t)),
TurnEvent::ToolStart { name, .. } => {
Some(StreamEvent::Status(tool_status(&name)))
}
_ => None,
};
if let Some(se) = mapped {
if fwd.send(se).await.is_err() {
break; }
}
}
};
let drive = self.runner.run_streaming(history, etx);
let (res, _) = tokio::join!(drive, forward);
match res {
Ok(result) => {
let reply = last_assistant_text(&result.messages);
let trimmed = trim_history(result.messages, MAX_HISTORY);
self.chats.lock().await.insert(chat_id.to_string(), trimmed);
let _ = out.send(StreamEvent::Final(reply)).await;
Ok(())
}
Err(e) => {
let _ = out.send(StreamEvent::Final(format!("⚠ {e}"))).await;
Err(e)
}
}
}
}
fn role_from_str(role: &str) -> Role {
match role {
"system" => Role::System,
"assistant" => Role::Assistant,
"tool" => Role::Tool,
_ => Role::User,
}
}
fn last_assistant_text(messages: &[Message]) -> String {
messages
.iter()
.rev()
.find(|m| matches!(m.role, Role::Assistant) && !m.content.trim().is_empty())
.map(|m| m.content.clone())
.unwrap_or_else(|| "(no reply)".to_string())
}
fn tool_status(name: &str) -> String {
let n = name.to_ascii_lowercase();
if n.contains("rag") || n.contains("search") || n.contains("memory") || n.contains("recall")
{
"🔍 recherche dans la mémoire…".to_string()
} else if n.contains("read")
|| n.contains("view")
|| n.contains("get")
|| n.contains("list")
{
"📄 lecture…".to_string()
} else if n.contains("write") || n.contains("edit") || n.contains("append") {
"✏️ écriture…".to_string()
} else {
format!("🔧 {name}…")
}
}
fn trim_history(mut msgs: Vec<Message>, max: usize) -> Vec<Message> {
if msgs.len() <= max {
return msgs;
}
let keep_system = msgs.first().is_some_and(|m| matches!(m.role, Role::System));
let start = msgs.len() - max;
if keep_system {
let system = msgs[0].clone();
let mut out = Vec::with_capacity(max + 1);
out.push(system);
out.extend_from_slice(&msgs[start..]);
out
} else {
msgs.split_off(start)
}
}
async fn build_handler(config: &Config) -> anyhow::Result<Arc<RunnerHandler>> {
let provider = crate::build_provider(config)?;
let registry = crate::build_serve_registry().await?;
let project = crate::project_slug(&std::env::current_dir()?);
let runner = AgentRunner::new(provider, registry, config.model.clone())
.with_max_iterations(config.max_iterations)
.with_skills(crate::load_all_skills())
.with_project(project);
Ok(Arc::new(RunnerHandler {
runner,
system_prompt: config.system_prompt.clone(),
chats: Mutex::new(HashMap::new()),
}))
}
#[cfg(any(feature = "telegram", feature = "discord"))]
fn announce(channel: &str, allowed: usize, setup_cmd: &str) {
if allowed == 0 {
eprintln!(
"aonyx: {channel} bot starting — OPEN to all chats \
(lock it down with `{setup_cmd}`). Ctrl-C to stop."
);
} else {
eprintln!("aonyx: {channel} bot starting — {allowed} allowed. Ctrl-C to stop.");
}
}
#[cfg(feature = "telegram")]
pub async fn telegram() -> anyhow::Result<()> {
use aonyx_adapters::{telegram::TelegramAdapter, ConversationAdapter};
let config = Config::load_or_init()?;
let token = crate::resolve_key(&None, "TELEGRAM_BOT_TOKEN", "telegram_bot_token").map_err(
|_| {
anyhow::anyhow!(
"no Telegram bot token — run `aonyx setup telegram`, or export TELEGRAM_BOT_TOKEN"
)
},
)?;
let handler = build_handler(&config).await?;
let allowed = config.telegram_allowed_chats.clone();
announce("Telegram", allowed.len(), "aonyx setup telegram");
TelegramAdapter::new(token, allowed, handler)
.run()
.await
.map_err(|e| anyhow::anyhow!("telegram: {e}"))
}
#[cfg(feature = "discord")]
pub async fn discord() -> anyhow::Result<()> {
use aonyx_adapters::{discord::DiscordAdapter, ConversationAdapter};
let config = Config::load_or_init()?;
let token =
crate::resolve_key(&None, "DISCORD_BOT_TOKEN", "discord_bot_token").map_err(|_| {
anyhow::anyhow!(
"no Discord bot token — run `aonyx setup discord`, or export DISCORD_BOT_TOKEN"
)
})?;
let handler = build_handler(&config).await?;
let allowed = config.discord_allowed_channels.clone();
announce("Discord", allowed.len(), "aonyx setup discord");
DiscordAdapter::new(token, allowed, handler)
.run()
.await
.map_err(|e| anyhow::anyhow!("discord: {e}"))
}
#[cfg(feature = "openai-server")]
pub async fn openai(port: u16, token: Option<String>) -> anyhow::Result<()> {
use aonyx_adapters::openai_server::OpenAiServer;
let config = Config::load_or_init()?;
let handler = build_handler(&config).await?;
let token = token.or_else(|| std::env::var("AONYX_OPENAI_TOKEN").ok());
let addr = format!("127.0.0.1:{port}");
if token.is_some() {
eprintln!(
"aonyx: OpenAI-compatible server on http://{addr}/v1 (bearer auth ON). Ctrl-C to stop."
);
} else {
eprintln!(
"aonyx: OpenAI-compatible server on http://{addr}/v1 \
(no auth — keep it on localhost). Ctrl-C to stop."
);
}
OpenAiServer::new(addr, token, handler)
.run()
.await
.map_err(|e| anyhow::anyhow!("openai-server: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(role: Role, c: &str) -> Message {
Message::new(role, c)
}
#[test]
fn trim_keeps_system_and_tail() {
let mut v = vec![msg(Role::System, "sys")];
for i in 0..100 {
v.push(msg(Role::User, &format!("u{i}")));
}
let out = trim_history(v, 10);
assert_eq!(out.len(), 11); assert!(matches!(out[0].role, Role::System));
assert_eq!(out[0].content, "sys");
assert_eq!(out.last().unwrap().content, "u99");
}
#[test]
fn trim_noop_when_small() {
let v = vec![msg(Role::User, "a"), msg(Role::Assistant, "b")];
assert_eq!(trim_history(v.clone(), 40).len(), v.len());
}
#[test]
fn last_assistant_text_picks_final_nonempty() {
let v = vec![
msg(Role::User, "q"),
msg(Role::Assistant, "first"),
msg(Role::User, "q2"),
msg(Role::Assistant, "final"),
];
assert_eq!(last_assistant_text(&v), "final");
}
#[test]
fn role_mapping() {
assert!(matches!(role_from_str("system"), Role::System));
assert!(matches!(role_from_str("assistant"), Role::Assistant));
assert!(matches!(role_from_str("tool"), Role::Tool));
assert!(matches!(role_from_str("user"), Role::User));
assert!(matches!(role_from_str("whatever"), Role::User));
}
}
}