use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use clap::{ArgAction, Args, Parser, Subcommand}; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::task::JoinHandle; use crate::atirc::{ format_reply, is_no_reply, mentions, synthetic_sub_command, AtircClient, AtircEvent, ChannelMessage, ConnectionInfo, LibraryAtircClient, }; use crate::backend::{AgentBackend, MockBackend}; use crate::code_mode::{ CodeModeLimits, CodeModeOptions, CodeModeToolExecutor, SandboxKind, WatchLimits, WatchNotice, }; use crate::error::AgiError; use crate::kv::MemoryKvStore; use crate::mcp::{McpToolExecutor, StdioServerConfig}; use crate::monitor::MonitorRunner; use crate::openai_backend::OpenAiCompatibleBackend; use crate::orchestrator::{Notice, Orchestrator, OrchestratorConfig, OrchestratorHandle}; use crate::output::OutputMessage; use crate::session::{ClaudeCodeCliHarness, MonitorEvent, SessionHarness, SessionLogger}; use crate::tools::{ToolExecutor, ToolObserver}; use uuid::Uuid; #[derive(Debug, Parser)] #[command( name = "asyncai", version, about = "Tokio port of the core agi.py event loop" )] pub struct Cli { /// Model name for an OpenAI-compatible endpoint #[arg(long, global = true)] pub model: Option, /// Agent name; defaults to the atirc connection nick or "alice" #[arg(long = "agent-name", global = true)] pub agent_name: Option, /// Base URL for an OpenAI-compatible endpoint #[arg(long = "base-url", global = true)] pub base_url: Option, /// API key for the OpenAI-compatible endpoint #[arg(long = "api-key", global = true)] pub api_key: Option, /// Stdio MCP server command; repeat for multiple servers #[arg(long = "mcp-stdio", global = true, value_name = "COMMAND")] pub mcp_stdio: Vec, /// Where run_code Python runs: auto (pool if a monty worker is found), in-process, pool #[arg(long = "code-sandbox", global = true, default_value = "auto")] pub code_sandbox: SandboxKind, /// monty worker binary for the pool sandbox [env: MONTY_WORKER; default: monty on PATH] #[arg(long = "monty-worker", global = true, value_name = "PATH")] pub monty_worker: Option, /// Heap cap per pool worker, in bytes #[arg( long = "code-max-memory", global = true, value_name = "BYTES", default_value_t = 4 * 1024 * 1024 * 1024 )] pub code_max_memory: usize, /// Python run in every new run_code scope after the built-in helpers #[arg(long = "code-preamble", global = true, value_name = "PATH")] pub code_preamble: Option, /// Max tokens the model may generate per request #[arg(long = "max-output-tokens", global = true, value_name = "N", default_value_t = crate::openai_backend::DEFAULT_MAX_OUTPUT_TOKENS)] pub max_output_tokens: u32, /// Wall-clock limit per model request, in seconds #[arg(long = "model-timeout", global = true, value_name = "SECS", default_value_t = crate::openai_backend::DEFAULT_REQUEST_TIMEOUT.as_secs())] pub model_timeout: u64, /// JSON file that keeps watch specs across restarts (written on watch start/stop) #[arg(long = "watch-state", global = true, value_name = "PATH")] pub watch_state: Option, /// Markdown prompt file; {agent_name} is substituted #[arg( long = "prompt-file", global = true, value_name = "PATH", conflicts_with = "prompt" )] pub prompt_file: Option, /// Built-in prompt: atirc, interactive, atirc-socialweb-computer #[arg(long = "prompt", global = true, value_name = "NAME")] pub prompt: Option, /// Per-format session root, FORMAT=PATH; repeatable #[arg(long = "session-dir", global = true, value_name = "FORMAT=PATH")] pub session_dir: Vec, /// Session harness format; repeatable; default claude-code-cli #[arg(long = "session-format", global = true, value_name = "FORMAT")] pub session_format: Vec, /// Disable session transcript writing #[arg(long = "no-session", global = true)] pub no_session: bool, /// Do not read stdin; run monitor commands only #[arg(long = "no-stdin", global = true)] pub no_stdin: bool, /// Monitor command; stdout lines become task notifications in session files; repeatable #[arg(long = "monitor", global = true, value_name = "COMMAND")] pub monitor: Vec, #[command(subcommand)] pub command: Option, } #[derive(Debug, Subcommand)] pub enum Command { /// Subscribe to atirc channels and reply through the atirc library Atirc(AtircArgs), } #[derive(Debug, Args)] pub struct AtircArgs { /// Long-lived atirc token [env: ATIRC_TOKEN] #[arg(short = 't', long, value_name = "TOKEN")] pub token: Option, /// Channel filter; repeat for multiple channels; default: account handle #[arg( short = 'c', long = "channel", value_name = "CHANNEL", action = ArgAction::Append )] pub channels: Vec, /// Last sequence already seen; -1 = only new messages #[arg(long, default_value_t = -1, allow_hyphen_values = true)] pub cursor: i64, /// Stop after N published replies; 0 means unlimited #[arg(long, default_value_t = 0)] pub max_turns: usize, /// Only handle messages that mention the agent nick; default: all messages #[arg(long)] pub mention_only: bool, /// Log connection, inbound messages, publishes, and output records to stderr #[arg(long)] pub verbose: bool, } pub async fn run(mut cli: Cli) -> Result<(), Box> { let verbose = matches!(&cli.command, Some(Command::Atirc(args)) if args.verbose); let tools = connect_tool_executor(&cli, verbose).await?; match cli.command.take() { Some(Command::Atirc(args)) => { let token = resolve_atirc_token(&cli, &args).await?; let client = Arc::new(LibraryAtircClient::from_token(token).map_err(std::io::Error::other)?); run_atirc(cli, args, tools, client).await } None => run_interactive(cli, tools).await, } } async fn resolve_atirc_token( cli: &Cli, args: &AtircArgs, ) -> Result> { if let Some(token) = args.token.clone().or_else(|| env_first(&["ATIRC_TOKEN"])) { return Ok(token); } let username = env_first(&["ATP_USERNAME", "ATIRC_USERNAME"]); let password = env_first(&["ATP_PASSWORD", "ATIRC_PASSWORD"]); let (Some(username), Some(password)) = (username, password) else { return Err("atirc requires --token, ATIRC_TOKEN, or ATP_USERNAME + ATP_PASSWORD".into()); }; let nick = cli .agent_name .clone() .ok_or("auto-mint requires --agent-name for the atirc nick")?; let endpoint_value = env_first(&["ATIRC_ENDPOINT"]) .unwrap_or_else(|| atirc_client::DEFAULT_ENDPOINT.to_string()); let endpoint = atirc_client::endpoint::Endpoint::parse(&endpoint_value).map_err(std::io::Error::other)?; let pds = env_first(&["ATIRC_PDS"]); atirc_client::transport::mint_long_token(&endpoint, &username, &password, &nick, pds.as_deref()) .await .map_err(std::io::Error::other) .map_err(Into::into) } fn env_first(keys: &[&str]) -> Option { keys.iter() .find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty())) } const DEFAULT_INTERACTIVE_PROMPT: &str = include_str!("../prompts/interactive.md"); const DEFAULT_ATIRC_PROMPT: &str = include_str!("../prompts/atirc.md"); /// Addendum to the atirc prompt for socialweb-computer nodes. const SOCIALWEB_COMPUTER_ADDENDUM: &str = include_str!("../prompts/atirc-socialweb-computer.md"); /// Names accepted by `--prompt`. pub const PROMPT_NAMES: &[&str] = &["atirc", "interactive", "atirc-socialweb-computer"]; /// A built-in prompt by name. pub fn builtin_prompt(name: &str) -> Option { match name { "atirc" => Some(DEFAULT_ATIRC_PROMPT.to_string()), "interactive" => Some(DEFAULT_INTERACTIVE_PROMPT.to_string()), "atirc-socialweb-computer" => Some(format!( "{DEFAULT_ATIRC_PROMPT}{SOCIALWEB_COMPUTER_ADDENDUM}" )), _ => None, } } /// Instruction appended to the `On Start` section when it is delivered. pub const ON_START_INSTRUCTION: &str = "This is the operator's start-up instruction, not a user message. \ Do it now. Post only what the channel needs (what you set up, or a problem); otherwise reply exactly NO_REPLY as your message text (not inside run_code)."; /// Splits a prompt into the system prompt and its `On Start` section. /// /// A markdown heading whose text is `On Start` (any level, any case) starts /// the section; it runs until the next heading of the same or a higher /// level. The section (heading included) is removed from the system prompt. pub fn split_on_start(prompt: &str) -> (String, Option) { let heading = |line: &str| -> Option<(usize, String)> { let level = line.chars().take_while(|c| *c == '#').count(); let rest = &line[level..]; (level > 0 && rest.starts_with(' ')).then(|| (level, rest.trim().to_string())) }; let lines: Vec<&str> = prompt.lines().collect(); let Some((start, level)) = lines.iter().enumerate().find_map(|(index, line)| { heading(line) .filter(|(_, text)| text.eq_ignore_ascii_case("on start")) .map(|(level, _)| (index, level)) }) else { return (prompt.to_string(), None); }; let end = lines[start + 1..] .iter() .position(|line| heading(line).is_some_and(|(other, _)| other <= level)) .map(|offset| start + 1 + offset) .unwrap_or(lines.len()); let section = lines[start + 1..end].join("\n").trim().to_string(); let mut system: Vec<&str> = lines[..start].to_vec(); while system.last().is_some_and(|line| line.trim().is_empty()) { system.pop(); } if end < lines.len() { system.push(""); system.extend(&lines[end..]); } let mut system = system.join("\n"); system.push('\n'); (system, (!section.is_empty()).then_some(section)) } /// The system prompt plus the `On Start` text to deliver once at startup. fn load_prompt( cli: &Cli, default_prompt: &str, agent_name: &str, ) -> Result<(String, Option), Box> { let template = match (&cli.prompt_file, &cli.prompt) { (Some(path), _) => std::fs::read_to_string(path).map_err(|error| { std::io::Error::other(format!("failed to read prompt file {path}: {error}")) })?, (None, Some(name)) => builtin_prompt(name).ok_or_else(|| { std::io::Error::other(format!( "unknown --prompt {name:?}; use one of {}", PROMPT_NAMES.join(", ") )) })?, (None, None) => default_prompt.to_string(), }; Ok(split_on_start( &template.replace("{agent_name}", agent_name), )) } fn base_url(cli: &Cli) -> Option { cli.base_url .clone() .or_else(|| env_first(&["OPENAI_BASE_URL", "ASYNC_LLM_BASE_URL"])) } fn model_name(cli: &Cli) -> String { cli.model .clone() .or_else(|| env_first(&["OPENAI_MODEL", "ASYNC_LLM_MODEL"])) .unwrap_or_else(|| "local".to_string()) } fn parse_session_overrides( values: &[String], ) -> Result, Box> { let mut overrides = std::collections::HashMap::new(); for value in values { let Some((format, path)) = value.split_once('=') else { return Err("--session-dir expects FORMAT=PATH".into()); }; if format.is_empty() || path.is_empty() { return Err("--session-dir expects FORMAT=PATH".into()); } overrides.insert(format.to_string(), PathBuf::from(path)); } Ok(overrides) } fn create_session( cli: &Cli, agent_name: &str, ) -> Result, Box> { if cli.no_session { return Ok(None); } let cwd = std::env::current_dir()?; let model = model_name(cli); let overrides = parse_session_overrides(&cli.session_dir)?; let formats = if cli.session_format.is_empty() { vec!["claude-code-cli".to_string()] } else { cli.session_format.clone() }; let mut harnesses: Vec> = Vec::new(); for format in formats { match format.as_str() { "claude-code-cli" => harnesses.push(Box::new(ClaudeCodeCliHarness::create( overrides.get("claude-code-cli").map(PathBuf::as_path), &cwd, &model, )?)), other => { return Err(format!( "unknown session format {other:?}; available: claude-code-cli" ) .into()); } } } let session = SessionLogger::start(harnesses, agent_name)?; println!("{}", session.first_line()?); std::io::stdout().flush()?; Ok(Some(session)) } fn start_monitors( cli: &Cli, session: Option<&SessionLogger>, ) -> Result, Box> { if cli.monitor.is_empty() { return Ok(Vec::new()); } let session = session.ok_or_else(|| std::io::Error::other("--monitor requires session logging"))?; let tx = session.transcript_sender(); let mut runners = Vec::new(); for command in &cli.monitor { runners.push(MonitorRunner::spawn( command.clone(), format!("asyncai monitor: {command}"), tx.clone(), )?); } Ok(runners) } fn log_atirc_event( session: &SessionLogger, task_id: &str, description: &str, message: &ChannelMessage, ) { let event = MonitorEvent { task_id: task_id.to_string(), description: description.to_string(), event: message.text.clone(), source_uuid: Uuid::new_v4().to_string(), delivery_id: Uuid::new_v4().to_string(), human_turn: false, note: None, }; session.monitor_event(event.clone()); session.monitor_consumed(event, "absorbed_mid_turn"); } /// Tools for the agent plus the watch notices they produce (ADR 0005). #[derive(Default)] pub struct ToolSetup { pub executor: Option>, pub notices: Option>, } /// Next watch notice, or never when watches are off. async fn next_notice( notices: &mut Option>, ) -> Option { match notices { Some(receiver) => receiver.recv().await, None => std::future::pending().await, } } /// Tracks inputs (user lines, channel messages, watch notices) against runs. /// /// The runtime coalesces inputs that arrive during an active run into one /// queued follow-up run, so inputs and runs are not 1:1. Each /// `new_thread_run_created` claims every input not yet claimed; replies of /// that run go to the route of the most recent claimed input. #[derive(Debug, Default)] struct TurnTracker { unclaimed: Vec>, run_route: Option, active_runs: usize, } impl TurnTracker { fn input(&mut self, route: Option) { self.unclaimed.push(route); } fn observe(&mut self, message: &OutputMessage) { match event_type_of(message) { Some("new_thread_run_created") => { self.active_runs += 1; if let Some(route) = self.unclaimed.drain(..).rev().flatten().next() { self.run_route = Some(route); } } Some("thread_run_complete" | "thread_run_failed") => { self.active_runs = self.active_runs.saturating_sub(1); } _ => {} } } fn reply_route(&self) -> Option<&str> { self.run_route.as_deref() } /// No input waits for a run and no run is active. fn idle(&self) -> bool { self.unclaimed.is_empty() && self.active_runs == 0 } } /// Queues the prompt's `On Start` section as the first input: a session /// monitor event (never a user record) and an orchestrator notice. /// /// Watch notices already queued at startup (the `--watch-state` restore /// notice) are folded into the same message, so startup is one turn instead /// of two near-identical ones. fn deliver_on_start( text: &str, notices: &mut Option>, session: Option<&SessionLogger>, handle: &OrchestratorHandle, ) -> bool { let mut queued = Vec::new(); if let Some(receiver) = notices.as_mut() { while let Ok(notice) = receiver.try_recv() { queued.push(notice); } } crate::log::emit( "info", "prompt.on_start", json!({"chars": text.chars().count(), "folded_notices": queued.len()}), ); let mut message = format!("\n{text}\n\n{ON_START_INSTRUCTION}"); for notice in &queued { record_notice(notice, session); message.push_str("\n\n"); message.push_str(¬ice.render()); } if let Some(session) = session { let event = MonitorEvent { task_id: "on-start".to_string(), description: "prompt On Start".to_string(), event: text.to_string(), source_uuid: Uuid::new_v4().to_string(), delivery_id: Uuid::new_v4().to_string(), human_turn: false, note: Some(ON_START_INSTRUCTION.to_string()), }; session.monitor_event(event.clone()); session.monitor_consumed(event, "delivered_as_turn"); } handle .send_notice(Notice { source: "prompt:on-start".to_string(), text: message, }) .is_ok() } /// Records a watch notice in the session as a monitor event (never a user /// record) and hands it to the orchestrator. fn deliver_notice( notice: &WatchNotice, session: Option<&SessionLogger>, handle: &OrchestratorHandle, ) -> bool { record_notice(notice, session); handle .send_notice(Notice { source: notice.source(), text: notice.render(), }) .is_ok() } /// A watch notice as a session monitor event. fn record_notice(notice: &WatchNotice, session: Option<&SessionLogger>) { if let Some(session) = session { let event = MonitorEvent { task_id: format!("watch-{}", notice.name), description: format!("watch {} {}", notice.name, notice.uri), event: notice.lines.join("\n"), source_uuid: Uuid::new_v4().to_string(), delivery_id: Uuid::new_v4().to_string(), human_turn: false, note: Some(crate::code_mode::watch::WATCH_INSTRUCTION.to_string()), }; session.monitor_event(event.clone()); session.monitor_consumed(event, "delivered_as_turn"); } } /// Connects the `--mcp-stdio` servers and puts them behind code mode: the /// model gets `run_code`, and every MCP call is made from Monty. async fn connect_tool_executor( cli: &Cli, verbose: bool, ) -> Result> { if cli.mcp_stdio.is_empty() { return Ok(ToolSetup::default()); } let configs = cli .mcp_stdio .iter() .map(|spec| StdioServerConfig::parse(spec)) .collect::, _>>()?; let mcp = Arc::new(McpToolExecutor::connect_stdio(configs).await?); for duplicate in mcp.duplicates() { crate::log::emit( "warn", "mcp.duplicate_tool", json!({"tool": duplicate.name, "kept": duplicate.kept, "ignored": duplicate.ignored}), ); } let preamble = match &cli.code_preamble { Some(path) => Some( std::fs::read_to_string(path) .map_err(|error| format!("--code-preamble {}: {error}", path.display()))?, ), None => None, }; let options = CodeModeOptions { limits: CodeModeLimits { max_memory: cli.code_max_memory, ..CodeModeLimits::default() }, verbose, sandbox: cli.code_sandbox, monty_worker: cli.monty_worker.clone(), preamble, resources: Some(mcp.clone()), watch_limits: WatchLimits::default(), watch_state: cli.watch_state.clone(), }; let code_mode = CodeModeToolExecutor::with_options(mcp, options).await?; if verbose { crate::log::emit( "info", "code_mode.sandbox", json!({ "sandbox": code_mode.sandbox_name(), "max_memory": (code_mode.sandbox_name() == "pool").then_some(cli.code_max_memory), "tools": code_mode.catalog().entries().len(), }), ); } let notices = code_mode.take_watch_notices(); // Notices queue on an unbounded channel until the agent loop reads them. code_mode.restore_watches().await; Ok(ToolSetup { executor: Some(Arc::new(code_mode)), notices, }) } fn make_backend( cli: &Cli, tool_executor: Option>, tool_observer: Option>, verbose_logging: bool, ) -> Result, Box> { let base_url = base_url(cli); match base_url { Some(base_url) => { let model = model_name(cli); let mut backend = OpenAiCompatibleBackend::new(base_url, model); if let Some(api_key) = cli .api_key .clone() .or_else(|| env_first(&["OPENAI_API_KEY", "ASYNC_LLM_API_KEY"])) { backend = backend.with_api_key(api_key); } if let Some(executor) = tool_executor { backend = backend.with_tool_executor(executor); } if let Some(observer) = tool_observer { backend = backend.with_tool_observer(observer); } backend = backend .with_verbose_logging(verbose_logging) .with_max_output_tokens(cli.max_output_tokens) .with_request_timeout(Duration::from_secs(cli.model_timeout)); Ok(Arc::new(backend)) } None if tool_executor.is_some() => { Err("--mcp-stdio requires --base-url or OPENAI_BASE_URL for tool calling".into()) } None => Ok(Arc::new(MockBackend::echo())), } } fn orchestrator_handle( backend: Arc, agent_name: String, instructions: String, ) -> (OrchestratorHandle, JoinHandle>) { let config = OrchestratorConfig { agi_name: agent_name, agent_instructions: instructions, ..Default::default() }; let (orchestrator, handle) = Orchestrator::new(backend, Arc::new(MemoryKvStore::default()), config); (handle, tokio::spawn(orchestrator.run())) } async fn run_interactive(cli: Cli, tools: ToolSetup) -> Result<(), Box> { let ToolSetup { executor: tool_executor, mut notices, } = tools; let agent_name = cli .agent_name .clone() .unwrap_or_else(|| "alice".to_string()); let (instructions, on_start) = load_prompt(&cli, DEFAULT_INTERACTIVE_PROMPT, &agent_name)?; let session = create_session(&cli, &agent_name)?; if cli.no_stdin { if cli.monitor.is_empty() { return Err("--no-stdin requires --monitor".into()); } let monitors = start_monitors(&cli, session.as_ref())?; for monitor in monitors { monitor.wait().await; } if let Some(session) = session { session.close().await; } return Ok(()); } let backend = make_backend( &cli, tool_executor, session.as_ref().map(|session| session.tool_observer()), false, )?; let monitors = start_monitors(&cli, session.as_ref())?; let (mut handle, mut run) = orchestrator_handle(backend, agent_name, instructions); let mut lines = BufReader::new(tokio::io::stdin()).lines(); let mut input_open = true; let mut turns = TurnTracker::default(); if let Some(text) = &on_start { turns.input(None); deliver_on_start(text, &mut notices, session.as_ref(), &handle); } loop { tokio::select! { line = lines.next_line(), if input_open => { match line { Ok(Some(line)) => { turns.input(None); if handle.input_tx.send(line.clone()).is_err() { break; } if let Some(session) = &session { session.user_message(line); } } Ok(None) => { input_open = false; if turns.idle() { break; } } Err(error) => { crate::log::emit( "error", "stdin.read_failed", json!({"error": error.to_string()}), ); input_open = false; if turns.idle() { break; } } } } notice = next_notice(&mut notices) => { match notice { Some(notice) => { turns.input(None); if !deliver_notice(¬ice, session.as_ref(), &handle) { break; } } None => notices = None, } } output = handle.output_rx.recv() => { match output { Some(message) => { if event_type_of(&message) == Some("new_thread_message") && event_data(&message)["message_role"] == "assistant" { if let (Some(session), Some(text)) = (&session, assistant_text(&message)) { session.assistant_message(text); } } turns.observe(&message); match message.to_ndjson() { Ok(line) => println!("{line}"), Err(error) => crate::log::emit( "error", "output.serialize_failed", json!({"error": error.to_string()}), ), } if !input_open && turns.idle() { break; } } None => break, } } result = &mut run => { report_run_result(result); break; } } } run.abort(); let _ = run.await; for monitor in monitors { monitor.finish(Duration::from_millis(250)).await; } if let Some(session) = session { session.close().await; } Ok(()) } async fn run_atirc( cli: Cli, args: AtircArgs, tools: ToolSetup, client: Arc, ) -> Result<(), Box> { let ToolSetup { executor: tool_executor, mut notices, } = tools; let client_identity = client.identity(); let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); let subscription_client = Arc::clone(&client); let channels = args.channels.clone(); let cursor = args.cursor; let subscription = tokio::spawn(async move { if let Err(error) = subscription_client .subscribe(channels, cursor, event_tx) .await { crate::log::emit( "error", "atirc.subscription_ended", json!({"error": error.to_string()}), ); } }); let connection = wait_for_connection(&mut event_rx).await?; if args.verbose { eprintln!( "{}", serde_json::json!({ "level": "info", "event": "atirc.connected", "agent_name": connection.subscribed_as.clone(), "subhandle": client_identity.subhandle.clone(), "channels": connection.channels.clone(), "transport": connection.transport.clone(), }) ); } let agent_name = cli .agent_name .clone() .unwrap_or_else(|| connection.subscribed_as.clone()); let (instructions, on_start) = load_prompt(&cli, DEFAULT_ATIRC_PROMPT, &agent_name)?; let session = create_session(&cli, &agent_name)?; let backend = make_backend( &cli, tool_executor, session.as_ref().map(|session| session.tool_observer()), args.verbose, )?; let monitors = start_monitors(&cli, session.as_ref())?; let (mut handle, mut run) = orchestrator_handle(backend, agent_name.clone(), instructions); let atirc_task_id = format!("atirc-{}", &Uuid::new_v4().simple().to_string()[..8]); let sub_command = synthetic_sub_command(&args.channels, args.cursor); let mut subscription_open = true; let mut published_turns = 0usize; let mut turns = TurnTracker::default(); // Watch notices reply where the agent was last addressed. let mut last_channel: Option = connection.channels.first().cloned(); if let Some(text) = &on_start { turns.input(last_channel.clone()); deliver_on_start(text, &mut notices, session.as_ref(), &handle); } loop { let at_limit = args.max_turns > 0 && published_turns >= args.max_turns; if turns.idle() && (!subscription_open || at_limit) { break; } tokio::select! { event = event_rx.recv() => { match event { Some(AtircEvent::Message(message)) => { if args.verbose { eprintln!( "{}", serde_json::json!({ "level": "info", "event": "atirc.message", "channel": message.channel.clone(), "identity": message.identity.clone(), "text": message.text.clone(), }) ); } let addressed = !args.mention_only || mentions(&message.text, &agent_name); let under_limit = args.max_turns == 0 || published_turns < args.max_turns; if !client_identity.is_self(message.identity.as_deref()) && addressed && under_limit { if let Some(session) = &session { log_atirc_event( session, &atirc_task_id, &sub_command, &message, ); } last_channel = Some(message.channel.clone()); turns.input(Some(message.channel)); if handle.input_tx.send(message.text).is_err() { break; } } } Some(AtircEvent::Connected(_)) => {} None => subscription_open = false, } } notice = next_notice(&mut notices) => { match notice { Some(notice) => { turns.input(last_channel.clone()); if !deliver_notice(¬ice, session.as_ref(), &handle) { break; } } None => notices = None, } } output = handle.output_rx.recv() => { match output { Some(message) => { if args.verbose { match message.to_ndjson() { Ok(line) => eprintln!("{line}"), Err(error) => crate::log::emit( "error", "output.serialize_failed", json!({"error": error.to_string()}), ), } } turns.observe(&message); match event_type_of(&message) { Some("new_thread_message") if event_data(&message)["message_role"] == "assistant" => { if let Some(content) = assistant_text(&message) { if let Some(session) = &session { session.assistant_message(content.as_str()); } if let Some(channel) = turns.reply_route().map(str::to_string) { let under_limit = args.max_turns == 0 || published_turns < args.max_turns; if under_limit && is_no_reply(&content) { crate::log::emit( "info", "reply.suppressed", json!({"channel": channel, "chars": content.chars().count()}), ); } else if under_limit { let replies = format_reply(&content); let parts = replies.len(); let mut all_published = true; for (index, reply) in replies.iter().enumerate() { match client.publish(&channel, reply).await { Ok(()) => { if args.verbose { eprintln!( "{}", serde_json::json!({ "level": "info", "event": "atirc.published", "channel": channel, "part": index + 1, "parts": parts, "bytes": reply.len(), }) ); } } Err(error) => { all_published = false; eprintln!( "{}", serde_json::json!({ "level": "error", "event": "atirc.publish_failed", "channel": channel, "part": index + 1, "parts": parts, "error": error, }) ); } } } if all_published && parts > 0 { published_turns += 1; } } } } } _ => {} } } None => break, } } result = &mut run => { report_run_result(result); break; } } } run.abort(); let _ = run.await; subscription.abort(); for monitor in monitors { monitor.finish(Duration::from_millis(250)).await; } if let Some(session) = session { session.close().await; } Ok(()) } async fn wait_for_connection( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Result> { loop { match tokio::time::timeout(Duration::from_secs(30), rx.recv()).await { Ok(Some(AtircEvent::Connected(connection))) => return Ok(connection), Ok(Some(AtircEvent::Message(_))) => continue, Ok(None) => return Err("atirc subscription ended before connecting".into()), Err(_) => return Err("timed out waiting for atirc connection".into()), } } } fn event_type_of(message: &OutputMessage) -> Option<&str> { message.result.get("event_type").and_then(Value::as_str) } fn event_data(message: &OutputMessage) -> &Value { &message.result["event_data"] } fn assistant_text(message: &OutputMessage) -> Option { let content = event_data(message).get("message_content")?; if let Some(text) = content.get("text").and_then(Value::as_str) { return Some(text.to_string()); } content.get("json").map(Value::to_string) } fn report_run_result(result: Result, tokio::task::JoinError>) { match result { Ok(Ok(())) => {} Ok(Err(error)) => crate::log::emit( "error", "orchestrator.failed", json!({"error": error.to_string()}), ), Err(error) => crate::log::emit( "error", "orchestrator.task_failed", json!({"error": error.to_string()}), ), } } #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use std::sync::Mutex; fn agent_event(event_type: &str) -> OutputMessage { OutputMessage::new( "main.agent.events", serde_json::json!({"event_type": event_type, "event_data": {}}), ) } #[test] fn turn_tracker_routes_coalesced_inputs_to_the_latest_channel() { let mut turns = TurnTracker::default(); assert!(turns.idle()); turns.input(Some("#a".to_string())); assert!(!turns.idle()); turns.observe(&agent_event("new_thread_run_created")); assert_eq!(turns.reply_route(), Some("#a")); // Two inputs arrive during the run: the runtime coalesces them into // one follow-up run, which claims both. turns.input(Some("#b".to_string())); turns.input(None); turns.observe(&agent_event("thread_run_in_progress")); turns.observe(&agent_event("thread_run_complete")); assert!(!turns.idle(), "unclaimed inputs keep the loop alive"); turns.observe(&agent_event("new_thread_run_created")); assert_eq!( turns.reply_route(), Some("#b"), "a notice without a route keeps the last channel" ); turns.observe(&agent_event("thread_run_failed")); assert!(turns.idle()); } use async_trait::async_trait; use tokio::sync::mpsc::UnboundedSender; use super::*; use crate::atirc::{AtircEvent, AtircIdentity, ChannelMessage, ConnectionInfo}; #[test] fn parses_model_and_agent_name_flags() { let cli = Cli::try_parse_from([ "asyncai", "--model", "some-model", "--agent-name", "alice", "atirc", "--channel", "#test_asyncai", "--cursor", "-1", "--max-turns", "2", ]) .unwrap(); assert_eq!(cli.model.as_deref(), Some("some-model")); assert_eq!(cli.agent_name.as_deref(), Some("alice")); let Some(Command::Atirc(args)) = cli.command else { panic!("expected atirc command"); }; assert_eq!(args.channels, vec!["#test_asyncai"]); assert_eq!(args.cursor, -1); assert_eq!(args.max_turns, 2); } #[test] fn parses_plain_interactive_mode() { let cli = Cli::try_parse_from(["asyncai", "--agent-name", "bob"]).unwrap(); assert_eq!(cli.agent_name.as_deref(), Some("bob")); assert!(cli.command.is_none()); } fn test_cli(session_root: &Path) -> Cli { Cli { model: None, agent_name: Some("sess-agent".to_string()), base_url: None, api_key: None, mcp_stdio: Vec::new(), code_sandbox: SandboxKind::InProcess, monty_worker: None, code_max_memory: CodeModeLimits::default().max_memory, code_preamble: None, watch_state: None, max_output_tokens: crate::openai_backend::DEFAULT_MAX_OUTPUT_TOKENS, model_timeout: 600, prompt_file: None, prompt: None, session_dir: vec![format!("claude-code-cli={}", session_root.display())], session_format: Vec::new(), no_session: false, no_stdin: false, monitor: Vec::new(), command: None, } } fn transcript_files(root: &Path) -> Vec { let mut files = Vec::new(); let Ok(projects) = std::fs::read_dir(root.join("projects")) else { return files; }; for project in projects.flatten() { let Ok(entries) = std::fs::read_dir(project.path()) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|value| value.to_str()) == Some("jsonl") { files.push(path); } } } files } fn transcript_records(root: &Path) -> Vec { let files = transcript_files(root); assert_eq!(files.len(), 1, "expected one transcript: {files:?}"); std::fs::read_to_string(&files[0]) .unwrap() .lines() .map(|line| serde_json::from_str(line).unwrap()) .collect() } struct MockAtircClient { identity: AtircIdentity, events: Mutex>>, published: Mutex>, } #[async_trait] impl AtircClient for MockAtircClient { fn identity(&self) -> AtircIdentity { self.identity.clone() } async fn subscribe( &self, _channels: Vec, _cursor: i64, tx: UnboundedSender, ) -> Result<(), String> { let events = self.events.lock().unwrap().take().unwrap_or_default(); for event in events { let _ = tx.send(event); } Ok(()) } async fn publish(&self, channel: &str, text: &str) -> Result<(), String> { self.published .lock() .unwrap() .push((channel.to_string(), text.to_string())); Ok(()) } } #[tokio::test] async fn atirc_mode_subscribes_and_publishes_via_client() { let temp = tempfile::tempdir().unwrap(); let identity = AtircIdentity { nick: "sess-agent".to_string(), handle: "example.test".to_string(), subhandle: "sess-agent.example.test".to_string(), }; let connection = ConnectionInfo { subscribed_as: "sess-agent".to_string(), owner_nick: Some("owner".to_string()), your_user_is: Some("example.test".to_string()), transport: Some("http".to_string()), identities: vec!["sess-agent@sess-agent.example.test".to_string()], channels: vec!["#test-asyncai".to_string()], }; let message = ChannelMessage { channel: "#test-asyncai".to_string(), identity: Some("tester.example.test".to_string()), text: "hello".to_string(), }; let self_message = ChannelMessage { channel: "#test-asyncai".to_string(), identity: Some("sess-agent.example.test".to_string()), text: "self message".to_string(), }; let client = Arc::new(MockAtircClient { identity, events: Mutex::new(Some(vec![ AtircEvent::Connected(connection), AtircEvent::Message(message), AtircEvent::Message(self_message), ])), published: Mutex::new(Vec::new()), }); run_atirc( test_cli(temp.path()), AtircArgs { token: None, channels: vec!["#test-asyncai".to_string()], cursor: -1, max_turns: 1, mention_only: false, verbose: false, }, ToolSetup::default(), client.clone(), ) .await .unwrap(); assert_eq!( *client.published.lock().unwrap(), vec![("#test-asyncai".to_string(), "hello".to_string())] ); let records = transcript_records(temp.path()); assert!(records.iter().any(|record| { record["type"] == "attachment" && record["attachment"]["type"] == "queued_command" })); assert!(records.iter().any(|record| { record["type"] == "assistant" && record["message"]["content"] .as_array() .is_some_and(|content| content.iter().any(|block| block["text"] == "hello")) })); assert!(!records.iter().any(|record| { record["type"] == "user" && record["message"]["content"] .as_str() .is_some_and(|content| content == "hello") })); } #[tokio::test] async fn atirc_mode_publishes_multiline_reply_as_separate_messages() { let temp = tempfile::tempdir().unwrap(); let identity = AtircIdentity { nick: "sess-agent".to_string(), handle: "example.test".to_string(), subhandle: "sess-agent.example.test".to_string(), }; let connection = ConnectionInfo { subscribed_as: "sess-agent".to_string(), owner_nick: Some("owner".to_string()), your_user_is: Some("example.test".to_string()), transport: Some("http".to_string()), identities: vec!["sess-agent@sess-agent.example.test".to_string()], channels: vec!["#test-asyncai".to_string()], }; let message = ChannelMessage { channel: "#test-asyncai".to_string(), identity: Some("tester.example.test".to_string()), text: "line one\nline two\n\n line three ".to_string(), }; let client = Arc::new(MockAtircClient { identity, events: Mutex::new(Some(vec![ AtircEvent::Connected(connection), AtircEvent::Message(message), ])), published: Mutex::new(Vec::new()), }); run_atirc( test_cli(temp.path()), AtircArgs { token: None, channels: vec!["#test-asyncai".to_string()], cursor: -1, max_turns: 1, mention_only: false, verbose: false, }, ToolSetup::default(), client.clone(), ) .await .unwrap(); assert_eq!( *client.published.lock().unwrap(), vec![ ("#test-asyncai".to_string(), "line one".to_string()), ("#test-asyncai".to_string(), "line two".to_string()), ("#test-asyncai".to_string(), "line three".to_string()), ] ); } #[test] fn on_start_sections_are_split_out() { let prompt = "You are x.\n\n## Rules\n- a\n\n## On start\n\nWatch bids.\n### detail\nkeep this in on-start\n\n## Later\n- b\n"; let (system, on_start) = split_on_start(prompt); assert_eq!(system, "You are x.\n\n## Rules\n- a\n\n## Later\n- b\n"); assert_eq!( on_start.as_deref(), Some("Watch bids.\n### detail\nkeep this in on-start") ); let (system, on_start) = split_on_start("# P\n\n# ON START\n\ndo it\n"); assert_eq!( (system.as_str(), on_start.as_deref()), ("# P\n", Some("do it")) ); let (system, on_start) = split_on_start("no section\n#On Start is not a heading\n"); assert_eq!(system, "no section\n#On Start is not a heading\n"); assert_eq!(on_start, None); let (_, empty) = split_on_start("x\n## On Start\n\n## Next\n"); assert_eq!(empty, None, "empty section delivers nothing"); let (system, on_start) = split_on_start(&builtin_prompt("atirc-socialweb-computer").unwrap()); assert!(!system.to_lowercase().contains("## on start"), "{system}"); assert!(on_start.unwrap().contains("monitors")); } #[tokio::test] async fn atirc_mode_delivers_on_start_first() { let temp = tempfile::tempdir().unwrap(); let prompt = temp.path().join("prompt.md"); std::fs::write( &prompt, "You are {agent_name}.\n\n## On Start\n\nset up monitors\n", ) .unwrap(); let mut cli = test_cli(temp.path()); cli.prompt_file = Some(prompt.display().to_string()); let identity = AtircIdentity { nick: "sess-agent".to_string(), handle: "example.test".to_string(), subhandle: "sess-agent.example.test".to_string(), }; let connection = ConnectionInfo { subscribed_as: "sess-agent".to_string(), owner_nick: Some("owner".to_string()), your_user_is: Some("example.test".to_string()), transport: Some("http".to_string()), identities: vec!["sess-agent@sess-agent.example.test".to_string()], channels: vec!["#test-asyncai".to_string()], }; let client = Arc::new(MockAtircClient { identity, events: Mutex::new(Some(vec![AtircEvent::Connected(connection)])), published: Mutex::new(Vec::new()), }); run_atirc( cli, AtircArgs { token: None, channels: vec!["#test-asyncai".to_string()], cursor: -1, max_turns: 1, mention_only: false, verbose: false, }, { // A restore notice already queued at startup. let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); sender .send(WatchNotice { name: "restore".to_string(), uri: "watches.json".to_string(), lines: vec!["restored bids kcp-events://watch/root/bids".to_string()], dropped: 0, }) .unwrap(); ToolSetup { executor: None, notices: Some(receiver), } }, client.clone(), ) .await .unwrap(); // The mock backend echoes its input: On Start and the queued restore // notice ran as one turn (max_turns 1) and replied to the first channel. let published = client.published.lock().unwrap().clone(); assert!( published .iter() .any(|(channel, text)| channel == "#test-asyncai" && text == "set up monitors"), "{published:?}" ); assert!( published .iter() .any(|(_, text)| text == "restored bids kcp-events://watch/root/bids"), "restore notice folded into the On Start turn: {published:?}" ); let transcript = std::fs::read_dir(temp.path()) .unwrap() .flatten() .flat_map(|entry| walk(&entry.path())) .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) .map(|path| std::fs::read_to_string(path).unwrap()) .expect("session transcript"); assert!(transcript.contains("prompt On Start"), "{transcript}"); assert!( !transcript.contains("\"type\":\"user\""), "On Start is a monitor event, not a user record" ); } fn walk(path: &std::path::Path) -> Vec { if path.is_dir() { std::fs::read_dir(path) .unwrap() .flatten() .flat_map(|entry| walk(&entry.path())) .collect() } else { vec![path.to_path_buf()] } } #[test] fn builtin_prompts_compose() { let socialweb = builtin_prompt("atirc-socialweb-computer").unwrap(); assert!(socialweb.starts_with(DEFAULT_ATIRC_PROMPT)); assert!(socialweb.contains("kcp-events://watch/root/")); assert_eq!(builtin_prompt("atirc").unwrap(), DEFAULT_ATIRC_PROMPT); assert!(builtin_prompt("nope").is_none()); for name in PROMPT_NAMES { assert!(builtin_prompt(name).is_some(), "{name}"); } } #[tokio::test] async fn atirc_mode_no_reply_publishes_nothing() { let temp = tempfile::tempdir().unwrap(); let identity = AtircIdentity { nick: "sess-agent".to_string(), handle: "example.test".to_string(), subhandle: "sess-agent.example.test".to_string(), }; let connection = ConnectionInfo { subscribed_as: "sess-agent".to_string(), owner_nick: Some("owner".to_string()), your_user_is: Some("example.test".to_string()), transport: Some("http".to_string()), identities: vec!["sess-agent@sess-agent.example.test".to_string()], channels: vec!["#test-asyncai".to_string()], }; let message = |text: &str| { AtircEvent::Message(ChannelMessage { channel: "#test-asyncai".to_string(), identity: Some("tester.example.test".to_string()), text: text.to_string(), }) }; let client = Arc::new(MockAtircClient { identity, events: Mutex::new(Some(vec![ AtircEvent::Connected(connection), message("`NO_REPLY`"), ])), published: Mutex::new(Vec::new()), }); run_atirc( test_cli(temp.path()), AtircArgs { token: None, channels: vec!["#test-asyncai".to_string()], cursor: -1, max_turns: 1, mention_only: false, verbose: false, }, ToolSetup::default(), client.clone(), ) .await .unwrap(); assert!(client.published.lock().unwrap().is_empty()); assert!(crate::atirc::is_no_reply("NO_REPLY.")); assert!(crate::atirc::is_no_reply("nothing new\nNO_REPLY")); assert!(crate::atirc::is_no_reply(" ")); assert!(!crate::atirc::is_no_reply("reply NO_REPLY if idle")); } #[tokio::test] async fn no_stdin_runs_monitors_without_user_records() { let temp = tempfile::tempdir().unwrap(); let mut cli = test_cli(temp.path()); cli.no_stdin = true; cli.monitor = vec!["printf 'monitor-only\\n'".to_string()]; run_interactive(cli, ToolSetup::default()).await.unwrap(); let records = transcript_records(temp.path()); assert!(records.iter().any(|record| { record["type"] == "queue-operation" && record["operation"] == "enqueue" })); assert!(!records.iter().any(|record| record["type"] == "user")); } }