diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index f9645e57..d43e47b4 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -102,13 +102,6 @@ text_enum!( } ); -impl AgentKind { - /// Whether the daemon can run bots of this agent yet. - pub fn runs(self) -> bool { - matches!(self, Self::Claude) - } -} - text_enum!( /// Which Claude model a bot runs on (spec 7.4): Claude Code's `--model` /// aliases, or the account's default. diff --git a/crates/botloftd/src/agent/agy.rs b/crates/botloftd/src/agent/agy.rs new file mode 100644 index 00000000..87bf291e --- /dev/null +++ b/crates/botloftd/src/agent/agy.rs @@ -0,0 +1,429 @@ +//! Antigravity's `agy` as an agent (spec 30). Experimental: in `-p` it asks +//! the owner for nothing, so Botloft fences it with deny rules and lets it use +//! only its own tools (spec 30.4). +//! +//! The process gets a home of its own, `/.botloft/agy-home`, as +//! `USERPROFILE`: `agy` reads `.gemini/config/mcp_config.json` and +//! `.gemini/antigravity-cli/settings.json` from there, so the owner's own +//! `GEMINI.md`, settings and MCP servers stay out, and the bot's token and +//! rules are its own. The login does not live there and carries over. + +use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{AgentKind, TokenUsage}; +use bytes::Bytes; +use serde_json::{Map, Value, json}; + +use super::{Agent, LaunchFiles, LaunchPlan, OutputDecoder, Turn}; +use crate::chat::sink; +use crate::state::Daemon; + +/// The bot's home, inside its folder. +fn home(workspace: &Path) -> PathBuf { + workspace.join(".botloft").join("agy-home") +} + +pub struct AgyAgent; + +impl Agent for AgyAgent { + fn kind(&self) -> AgentKind { + AgentKind::Agy + } + + fn locate(&self, configured: &str, _claude: &Path) -> io::Result { + let configured = configured.trim(); + if !configured.is_empty() { + return Ok(PathBuf::from(configured)); + } + let mut candidates = Vec::new(); + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + candidates.push(PathBuf::from(local).join("agy").join("bin").join("agy.exe")); + } + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + candidates.push(dir.join("agy.exe")); + candidates.push(dir.join("agy")); + } + } + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "agy was not found; install Antigravity or set agy_path in config.toml", + ) + }) + } + + fn args(&self, plan: &LaunchPlan<'_>) -> Vec { + let mut args: Vec = [ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + ] + .into_iter() + .map(OsString::from) + .collect(); + // A new conversation takes the id `agy` gives it (in `init`). + if plan.resumed { + args.push("--conversation".into()); + args.push(plan.session.into()); + } + args.push("--add-dir".into()); + args.push(plan.work_folder.as_os_str().to_owned()); + // `-p` takes the next argument as its prompt unless the value is + // attached; there is none, the prompts come on stdin. + args.push("-p=".into()); + args + } + + fn extra_env(&self, workspace: &Path) -> Vec<(OsString, OsString)> { + let home = home(workspace).into_os_string(); + vec![ + (OsString::from("USERPROFILE"), home.clone()), + (OsString::from("HOME"), home), + ] + } + + fn write_launch_files(&self, files: &LaunchFiles<'_>) -> io::Result<()> { + let home = home(files.workspace); + let config = home.join(".gemini").join("config"); + let cli = home.join(".gemini").join("antigravity-cli"); + std::fs::create_dir_all(&config)?; + std::fs::create_dir_all(&cli)?; + let mcp = json!({ + "mcpServers": { + "botloft": { + "disabled": false, + "serverUrl": format!("http://127.0.0.1:{}/mcp", files.port), + "headers": { "Authorization": format!("Bearer {}", files.token) }, + }, + }, + }); + std::fs::write(config.join("mcp_config.json"), pretty(&mcp))?; + std::fs::write(cli.join("settings.json"), pretty(&settings(files.fenced)))?; + // The rules `prepare_bot` wrote for Claude Code are the bot's rules; + // `agy` reads `AGENTS.md` from its folder. + let rules = files + .workspace + .join(".claude") + .join("rules") + .join("botloft.md"); + if let Ok(text) = std::fs::read_to_string(rules) { + std::fs::write(files.workspace.join("AGENTS.md"), text)?; + } + Ok(()) + } + + fn speaks_control(&self) -> bool { + // Not seen in `agy 1.3.1`; a Claude-format line is refused with an + // error in the turn (spec 30.4). + false + } + + fn encode_turn(&self, _uuid: &str, turn: &Turn) -> Bytes { + let mut content = vec![json!({ "type": "text", "text": turn.text })]; + content.extend(turn.images.iter().map(|image| { + json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.media_type, + "data": image.data, + }, + }) + })); + let event = json!({ + "event": "user", + "message": { "role": "user", "content": content }, + }); + let mut line = event.to_string().into_bytes(); + line.push(b'\n'); + Bytes::from(line) + } + + fn decoder(&self) -> Box { + Box::new(AgyDecoder::default()) + } +} + +fn pretty(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_default() +} + +/// A path as `agy`'s rules match it: forward slashes, drive letter kept +/// (seen with 1.3.1; without the drive the rule matched nothing). +fn rule_path(path: &Path) -> String { + let text = path.to_string_lossy(); + let text = text.strip_prefix(r"\\?\").unwrap_or(&text); + text.replace('\\', "/") +} + +/// `settings.json` (spec 30.3): only Botloft's MCP tools run without asking, +/// since in `-p` nothing can ask; whatever else needs a permission is denied +/// by `agy` itself. Reads and writes of the fenced folders are denied. +fn settings(fenced: &[PathBuf]) -> Value { + let mut deny = Vec::new(); + for folder in fenced { + let folder = rule_path(folder); + deny.push(format!("read_file({folder})")); + deny.push(format!("write_file({folder})")); + } + json!({ + "permissions": { + "allow": ["mcp(botloft/*)"], + "deny": deny, + }, + }) +} + +/// What one process of `agy` printed, as far as the next line needs it. +#[derive(Default)] +struct AgyDecoder { + /// The reply text written so far in the current response. + pending: String, +} + +impl OutputDecoder for AgyDecoder { + fn live_text<'a>(&mut self, event: &'a Value) -> Option<&'a str> { + let update = step_update(event)?; + if update["step_type"] != "agent_response" || update["state"] != "ACTIVE" { + return None; + } + let text = update["text_delta"] + .as_str() + .filter(|text| !text.is_empty())?; + self.pending.push_str(text); + Some(text) + } + + fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + if !daemon.supervisor.is_current(bot, generation) { + return; + } + match event["event"].as_str() { + Some("init") => { + if let Some(model) = event["init"]["model"].as_str() { + crate::service::models::reported(daemon, bot, model); + } + } + Some("step_update") => self.step(daemon, bot, generation, &event["step_update"]), + Some("result") => self.result(daemon, bot, generation, &event["result"]), + _ => {} + } + } +} + +fn step_update(event: &Value) -> Option<&Value> { + (event["event"] == "step_update").then(|| &event["step_update"]) +} + +impl AgyDecoder { + fn step(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, update: &Value) { + let conversation = update["conversation_id"].as_str().unwrap_or_default(); + match (update["step_type"].as_str(), update["state"].as_str()) { + // The message was taken up: `agy` gives no id back, so it was the + // oldest one written. + (Some("user_input"), Some("DONE")) => { + if !conversation.is_empty() { + sink::session_started(daemon, bot, conversation); + } + if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) { + sink::message_read(daemon, bot, &uuid); + } + } + (Some("agent_response"), Some("DONE")) => self.flush(daemon, bot), + (Some("tool"), Some("ACTIVE")) => { + // What came before the tool is a reply of its own. + self.flush(daemon, bot); + let (name, input) = vocabulary( + update["tool_name"].as_str().unwrap_or("tool"), + &update["tool_info"]["parameters"], + ); + sink::tool_started(daemon, bot, &tool_id(conversation, update), &name, &input); + } + (Some("tool"), Some(state @ ("DONE" | "ERROR"))) => { + let failed = state == "ERROR"; + let output = update["tool_info"]["error"]["message"] + .as_str() + .unwrap_or_default(); + sink::tool_finished(daemon, bot, &tool_id(conversation, update), failed, output); + } + _ => {} + } + } + + fn result(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, result: &Value) { + self.flush(daemon, bot); + let failed = result["status"].as_str() != Some("SUCCESS"); + let error = failed.then(|| { + result["error"] + .as_str() + .or_else(|| result["status"].as_str()) + .unwrap_or("error") + .to_owned() + }); + let count = |key: &str| result["usage"][key].as_u64().unwrap_or_default(); + let tokens = result["usage"].is_object().then(|| TokenUsage { + input: count("input_tokens"), + cache_write: 0, + reloaded: 0, + cache_read: count("cache_read_tokens"), + output: count("output_tokens") + count("thinking_tokens"), + }); + // `duration_seconds` is a float. + let seconds = result["duration_seconds"].as_f64().unwrap_or_default(); + sink::turn_finished(daemon, bot, (seconds * 1000.0) as u64, tokens, error); + sink::finish_turn(daemon, bot, generation, failed); + } + + /// The reply written so far becomes a chat item. + fn flush(&mut self, daemon: &Daemon, bot: &BotId) { + let text = std::mem::take(&mut self.pending); + sink::reply(daemon, bot, &text); + } +} + +/// An id for a tool step, unique among the bot's conversations. +fn tool_id(conversation: &str, update: &Value) -> String { + format!( + "{conversation}-{}", + update["step_index"].as_u64().unwrap_or_default() + ) +} + +/// The tool in the names the chat knows (spec 8.4), so that it shows as a +/// command or a file edit. Others keep their own name and parameters. +fn vocabulary(name: &str, parameters: &Value) -> (String, Value) { + let text = |key: &str| parameters[key].clone(); + let mut input = Map::new(); + let mapped = match name { + "write_to_file" => { + input.insert("file_path".into(), text("TargetFile")); + "Write" + } + "replace_file_content" | "multi_replace_file_content" | "sed_file" => { + input.insert("file_path".into(), text("TargetFile")); + "Edit" + } + "run_command" => { + input.insert("command".into(), text("CommandLine")); + "Bash" + } + "view_file" => { + input.insert("file_path".into(), text("AbsolutePath")); + "Read" + } + "search_web" => { + input.insert("query".into(), text("query")); + "WebSearch" + } + "read_url_content" => { + input.insert("url".into(), text("Url")); + "WebFetch" + } + _ => return (name.to_owned(), parameters.clone()), + }; + (mapped.to_owned(), Value::Object(input)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::TurnImage; + + #[test] + fn a_turn_is_one_user_event_with_its_images() { + let turn = Turn { + text: "hi".into(), + images: vec![TurnImage { + media_type: "image/png".into(), + data: "AAAA".into(), + }], + }; + let line = AgyAgent.encode_turn("ignored", &turn); + assert!(line.ends_with(b"\n")); + let json: Value = serde_json::from_slice(&line).expect("json"); + assert_eq!(json["event"], "user"); + assert_eq!(json["message"]["role"], "user"); + assert_eq!(json["message"]["content"][0]["text"], "hi"); + assert_eq!(json["message"]["content"][1]["source"]["data"], "AAAA"); + } + + #[test] + fn a_new_conversation_has_no_id_and_a_resumed_one_names_it() { + let plan = |resumed| LaunchPlan { + session: "conv-1", + resumed, + mcp_config: Path::new("m.json"), + work_folder: Path::new("w"), + permission_mode: botloft_core::protocol::PermissionMode::Default, + model: botloft_core::protocol::BotModel::Default, + effort: botloft_core::protocol::BotEffort::Default, + }; + let text = |args: Vec| -> Vec { + args.into_iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect() + }; + let fresh = text(AgyAgent.args(&plan(false))); + assert!(!fresh.contains(&"--conversation".to_owned())); + assert_eq!(fresh.last().map(String::as_str), Some("-p=")); + let resumed = text(AgyAgent.args(&plan(true))); + let at = resumed + .iter() + .position(|a| a == "--conversation") + .expect("flag"); + assert_eq!(resumed[at + 1], "conv-1"); + } + + #[test] + fn rules_name_folders_with_the_drive_and_forward_slashes() { + assert_eq!( + rule_path(Path::new(r"C:\Users\x\Botloft\a")), + "C:/Users/x/Botloft/a" + ); + assert_eq!(rule_path(Path::new(r"\\?\C:\Users\x")), "C:/Users/x"); + let settings = settings(&[PathBuf::from(r"C:\Data")]); + let deny = settings["permissions"]["deny"].as_array().expect("deny"); + assert!(deny.contains(&json!("write_file(C:/Data)"))); + assert!(deny.contains(&json!("read_file(C:/Data)"))); + assert_eq!(settings["permissions"]["allow"][0], "mcp(botloft/*)"); + } + + #[test] + fn tools_take_the_names_the_chat_knows() { + let (name, input) = vocabulary("run_command", &json!({ "CommandLine": "echo hi" })); + assert_eq!( + (name.as_str(), input["command"].as_str()), + ("Bash", Some("echo hi")) + ); + let (name, input) = vocabulary("write_to_file", &json!({ "TargetFile": "C:/a/b.txt" })); + assert_eq!( + (name.as_str(), input["file_path"].as_str()), + ("Write", Some("C:/a/b.txt")) + ); + let (name, _) = vocabulary("browser_get_dom", &json!({})); + assert_eq!(name, "browser_get_dom"); + } + + #[test] + fn reply_text_gathers_until_the_response_is_done() { + let mut decoder = AgyDecoder::default(); + let delta = |text: &str| { + json!({ "event": "step_update", "step_update": { + "step_type": "agent_response", "state": "ACTIVE", "text_delta": text } }) + }; + assert_eq!(decoder.live_text(&delta("po")), Some("po")); + assert_eq!(decoder.live_text(&delta("ng")), Some("ng")); + assert_eq!(decoder.pending, "pong"); + // A line of another kind is not live text. + let done = json!({ "event": "result", "result": {} }); + assert_eq!(decoder.live_text(&done), None); + } +} diff --git a/crates/botloftd/src/agent/claude.rs b/crates/botloftd/src/agent/claude.rs index a4ed31ef..ad0ce407 100644 --- a/crates/botloftd/src/agent/claude.rs +++ b/crates/botloftd/src/agent/claude.rs @@ -2,13 +2,15 @@ //! both directions. use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; use botloft_core::ids::BotId; use botloft_core::protocol::AgentKind; use bytes::Bytes; use serde_json::{Value, json}; -use super::{Agent, LaunchPlan, Turn}; +use super::{Agent, LaunchFiles, LaunchPlan, OutputDecoder, Turn}; use crate::chat::claude; use crate::state::Daemon; @@ -29,6 +31,11 @@ impl Agent for ClaudeAgent { AgentKind::Claude } + fn locate(&self, _configured: &str, claude: &Path) -> io::Result { + // Found and checked by the supervisor, which keeps it up to date. + Ok(claude.to_path_buf()) + } + fn args(&self, plan: &LaunchPlan<'_>) -> Vec { let mut args: Vec = [ "-p", @@ -84,11 +91,21 @@ impl Agent for ClaudeAgent { args } - fn extra_env(&self) -> Vec<(OsString, OsString)> { + fn extra_env(&self, _workspace: &Path) -> Vec<(OsString, OsString)> { // Loads the work folder's CLAUDE.md with the bot's memory. vec![(OsString::from(ADDITIONAL_MEMORY), OsString::from("1"))] } + fn write_launch_files(&self, _files: &LaunchFiles<'_>) -> io::Result<()> { + // `workspace::prepare_bot` writes everything Claude Code reads, and + // it expands the token from the environment. + Ok(()) + } + + fn speaks_control(&self) -> bool { + true + } + fn encode_turn(&self, uuid: &str, turn: &Turn) -> Bytes { let mut content = vec![json!({ "type": "text", "text": turn.text })]; content.extend(turn.images.iter().map(|image| { @@ -111,11 +128,20 @@ impl Agent for ClaudeAgent { Bytes::from(line) } - fn live_text<'a>(&self, event: &'a Value) -> Option<&'a str> { + fn decoder(&self) -> Box { + Box::new(ClaudeDecoder) + } +} + +/// Claude Code's stream keeps nothing between lines. +struct ClaudeDecoder; + +impl OutputDecoder for ClaudeDecoder { + fn live_text<'a>(&mut self, event: &'a Value) -> Option<&'a str> { claude::live_text(event) } - fn handle_output(&self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { claude::apply(daemon, bot, generation, event); } } diff --git a/crates/botloftd/src/agent/mod.rs b/crates/botloftd/src/agent/mod.rs index 5ea0a201..0dfcbf95 100644 --- a/crates/botloftd/src/agent/mod.rs +++ b/crates/botloftd/src/agent/mod.rs @@ -1,19 +1,21 @@ -//! The agent behind a bot (spec 30): Claude Code today, Codex and Antigravity -//! later. What is specific to one agent lives behind [`Agent`]; the rest of the -//! daemon asks it and never writes an agent's command line or stdin framing -//! itself. This first slice owns the process arguments and the encoding of a -//! turn; reading the output and the approvals follow (spec 30.1, A1). +//! The agent behind a bot (spec 30): Claude Code, and Antigravity (`agy`) as an +//! experimental one; Codex later. What is specific to one agent lives behind +//! [`Agent`]; the rest of the daemon asks it and never writes an agent's +//! command line, stdin framing or output format itself. +mod agy; mod claude; use std::ffi::OsString; -use std::path::Path; +use std::io; +use std::path::{Path, PathBuf}; use botloft_core::ids::BotId; use botloft_core::protocol::{AgentKind, BotEffort, BotModel, PermissionMode}; use bytes::Bytes; use serde_json::Value; +pub use agy::AgyAgent; pub use claude::ClaudeAgent; use crate::state::Daemon; @@ -35,7 +37,8 @@ pub struct Turn { /// What an agent needs to build the command line of a bot's process. pub struct LaunchPlan<'a> { - /// The conversation: resumed when `resumed`, new with this id otherwise. + /// A new conversation's id (Claude Code takes the one given) or, when + /// `resumed`, the one to resume. pub session: &'a str, pub resumed: bool, /// The bot's MCP configuration (the Botloft server and the owner's). @@ -47,37 +50,68 @@ pub struct LaunchPlan<'a> { pub effort: BotEffort, } +/// What an agent needs to write the files it reads at start, once the bot +/// has its token for this process. +pub struct LaunchFiles<'a> { + pub workspace: &'a Path, + pub port: u16, + pub token: &'a str, + /// Folders the bot must not read or edit with its own tools (spec 7.5). + pub fenced: &'a [PathBuf], +} + +/// Reads one process's output, line by line (already JSON). One per process, +/// so it may keep what it needs between lines. +pub trait OutputDecoder: Send { + /// The piece of reply text this line carries while the reply is still + /// being written, if that is what it is (spec 8.3). + fn live_text<'a>(&mut self, event: &'a Value) -> Option<&'a str>; + + /// What the line means for the chat and the bot's state: it saves items + /// and tells the supervisor, through `chat::sink` for what every agent has + /// and its own code for the rest. Lines from a process that was replaced + /// say nothing about the new one. + fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value); +} + pub trait Agent: Send + Sync + 'static { fn kind(&self) -> AgentKind; + /// The executable, from the configured path or the usual places. + fn locate(&self, configured: &str, claude: &Path) -> io::Result; + /// The arguments of the process, after the program. fn args(&self, plan: &LaunchPlan<'_>) -> Vec; /// Variables the agent needs beyond the ones every bot gets. - fn extra_env(&self) -> Vec<(OsString, OsString)>; + fn extra_env(&self, workspace: &Path) -> Vec<(OsString, OsString)>; + + /// Writes what the agent reads from the bot's folder at start and that + /// `workspace::prepare_bot` does not (spec 30.2). + fn write_launch_files(&self, files: &LaunchFiles<'_>) -> io::Result<()>; + + /// Whether the process answers Botloft's own requests on stdin (its + /// settings, how full its conversation is, the state of its MCP servers, + /// `/compact`). An agent that does not shows none of those (spec 30.2). + fn speaks_control(&self) -> bool; /// One turn as the bytes written to the process's stdin; `uuid` is what - /// the agent gives back when the turn begins. + /// the agent gives back when the turn begins, for those that do. fn encode_turn(&self, uuid: &str, turn: &Turn) -> Bytes; - /// The piece of reply text one line of output carries while the reply is - /// still being written, if that is what it is (spec 8.3). - fn live_text<'a>(&self, event: &'a Value) -> Option<&'a str>; - - /// What one line of output (already JSON) means for the chat and the - /// bot's state: it saves items and tells the supervisor, through - /// `chat::sink` for what every agent has and its own code for the rest. - /// Lines from a process that was replaced are ignored (spec 30). - fn handle_output(&self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value); + /// A reader for the output of one process. + fn decoder(&self) -> Box; } static CLAUDE: ClaudeAgent = ClaudeAgent; +static AGY: AgyAgent = AgyAgent; -/// The agent that runs bots of `kind`; `None` while the daemon cannot run -/// that agent yet (spec 30.1). +/// The agent that runs bots of `kind`; `None` for one the daemon does not +/// have yet (spec 30.1). pub fn of(kind: AgentKind) -> Option<&'static dyn Agent> { match kind { AgentKind::Claude => Some(&CLAUDE), - AgentKind::Codex | AgentKind::Agy => None, + AgentKind::Agy => Some(&AGY), + AgentKind::Codex => None, } } diff --git a/crates/botloftd/src/chat/claude.rs b/crates/botloftd/src/chat/claude.rs index c4456d0a..8edc3503 100644 --- a/crates/botloftd/src/chat/claude.rs +++ b/crates/botloftd/src/chat/claude.rs @@ -6,11 +6,11 @@ use botloft_core::ids::BotId; use botloft_core::protocol::TokenUsage; use serde_json::Value; -use tracing::{debug, warn}; +use tracing::debug; use super::{account, control, sink}; -use crate::state::{Daemon, Event}; -use crate::{context, routines, service}; +use crate::state::Daemon; +use crate::{context, service}; pub(crate) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { // Lines from a process that was replaced say nothing about the new one. @@ -123,16 +123,7 @@ fn replay(daemon: &Daemon, bot: &BotId, event: &Value) { let Some(uuid) = event["uuid"].as_str() else { return; }; - let now = daemon.clock.now_ms(); - let read = daemon.store().mark_read(uuid, now); - match read { - Ok(Some(delivery)) => { - routines::turn_began(daemon, bot, &delivery.message_id); - daemon.emit(Event::DeliveryChanged(delivery)); - } - Ok(None) => {} - Err(err) => warn!("could not mark a delivery read: {err}"), - } + sink::message_read(daemon, bot, uuid); } fn tool_results(daemon: &Daemon, bot: &BotId, event: &Value) { @@ -189,10 +180,7 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { error, ); } - routines::turn_ended(daemon, bot, failed); - crate::screens::turn_ended(daemon, bot); - daemon.crew_access.end_turn(bot); - daemon.supervisor.turn_ended(bot, generation); + sink::finish_turn(daemon, bot, generation, failed); } /// The turn's tokens from the `usage` of a `result`: the sum of the turn's diff --git a/crates/botloftd/src/chat/mod.rs b/crates/botloftd/src/chat/mod.rs index 2be530af..eb2b713e 100644 --- a/crates/botloftd/src/chat/mod.rs +++ b/crates/botloftd/src/chat/mod.rs @@ -13,7 +13,7 @@ use botloft_core::ids::BotId; use botloft_core::protocol::ChatDelta; use tracing::debug; -use crate::agent::Agent; +use crate::agent::{Agent, OutputDecoder}; use crate::state::{Daemon, Event}; /// Longest line kept; anything longer is dropped whole (spec 8.1). @@ -32,7 +32,7 @@ pub struct StreamReader { bot: BotId, generation: u64, /// Reads the lines: they are the agent's own protocol. - agent: &'static dyn Agent, + decoder: Box, buffer: Vec, /// Inside a line that grew past [`LINE_MAX`]; ends at its newline. skipping: bool, @@ -45,7 +45,7 @@ impl StreamReader { Self { bot, generation, - agent, + decoder: agent.decoder(), buffer: Vec::new(), skipping: false, live: String::new(), @@ -110,7 +110,7 @@ impl StreamReader { return; } }; - if let Some(text) = self.agent.live_text(&event) { + if let Some(text) = self.decoder.live_text(&event) { // Lines from a process that was replaced say nothing about the // new one. if daemon.supervisor.is_current(&self.bot, self.generation) { @@ -123,7 +123,7 @@ impl StreamReader { } // Whatever comes next follows the text before it. self.flush(daemon); - self.agent - .handle_output(daemon, &self.bot, self.generation, &event); + self.decoder + .handle(daemon, &self.bot, self.generation, &event); } } diff --git a/crates/botloftd/src/chat/session.rs b/crates/botloftd/src/chat/session.rs index bf8394fc..1f383543 100644 --- a/crates/botloftd/src/chat/session.rs +++ b/crates/botloftd/src/chat/session.rs @@ -4,7 +4,6 @@ use botloft_core::ids::BotId; use serde_json::Value; -use tracing::warn; use crate::state::Daemon; @@ -23,15 +22,7 @@ pub(super) fn began(daemon: &Daemon, bot: &BotId, event: &Value) { if !event["parent_tool_use_id"].is_null() { return; } - let store = daemon.store(); - let known = store.session_id(bot).ok().flatten(); - if known.as_deref() != Some(session) - && let Err(err) = store.set_session_id(bot, Some(session)) - { - warn!(bot = %bot, "could not save the session id: {err}"); - } - drop(store); - daemon.supervisor.remember_session(bot, session); + super::sink::session_started(daemon, bot, session); } /// Whether this `result` is Claude Code saying it could not find the diff --git a/crates/botloftd/src/chat/sink.rs b/crates/botloftd/src/chat/sink.rs index 54a97da7..0e2f034a 100644 --- a/crates/botloftd/src/chat/sink.rs +++ b/crates/botloftd/src/chat/sink.rs @@ -9,8 +9,11 @@ use botloft_core::ids::BotId; use botloft_core::protocol::{ChatBody, ReplyItem, TokenUsage, ToolItem, ToolStatus, TurnItem}; use serde_json::Value; +use tracing::warn; + use super::items; -use crate::state::Daemon; +use crate::state::{Daemon, Event}; +use crate::{routines, screens}; /// The bot's reply text; nothing for an empty one. pub(crate) fn reply(daemon: &Daemon, bot: &BotId, text: &str) { @@ -84,3 +87,40 @@ pub(crate) fn turn_finished( }), ); } + +/// The message written with `uuid` was taken up by the bot: its delivery is +/// read, and a routine's run starts (spec 9.1). +pub(crate) fn message_read(daemon: &Daemon, bot: &BotId, uuid: &str) { + let now = daemon.clock.now_ms(); + let read = daemon.store().mark_read(uuid, now); + match read { + Ok(Some(delivery)) => { + routines::turn_began(daemon, bot, &delivery.message_id); + daemon.emit(Event::DeliveryChanged(delivery)); + } + Ok(None) => {} + Err(err) => warn!("could not mark a delivery read: {err}"), + } +} + +/// The agent has a conversation the next start resumes (spec 7.3). +pub(crate) fn session_started(daemon: &Daemon, bot: &BotId, session: &str) { + let store = daemon.store(); + let known = store.session_id(bot).ok().flatten(); + if known.as_deref() != Some(session) + && let Err(err) = store.set_session_id(bot, Some(session)) + { + warn!(bot = %bot, "could not save the session id: {err}"); + } + drop(store); + daemon.supervisor.remember_session(bot, session); +} + +/// Everything that follows the end of a turn, whatever the agent: the +/// routine's run, the screens, the access to other crews, the bot's state. +pub(crate) fn finish_turn(daemon: &Daemon, bot: &BotId, generation: u64, failed: bool) { + routines::turn_ended(daemon, bot, failed); + screens::turn_ended(daemon, bot); + daemon.crew_access.end_turn(bot); + daemon.supervisor.turn_ended(bot, generation); +} diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs index fb7a8cc9..8c30f6e7 100644 --- a/crates/botloftd/src/config.rs +++ b/crates/botloftd/src/config.rs @@ -14,6 +14,11 @@ pub struct Config { pub workspaces_root: String, /// Empty means resolve `claude` from PATH. pub claude_path: String, + /// Empty means look for `agy` in its installer's folder and on PATH. + pub agy_path: String, + /// Agents not meant for everyone yet, by name (`"agy"`, spec 30). A bot + /// can only be made for one of these when it is listed here. + pub experimental_agents: Vec, /// Start when the owner signs in to Windows (spec 14). pub start_with_windows: bool, /// Keep the computer awake while a bot works (spec 14). @@ -33,6 +38,8 @@ impl Default for Config { port: 45710, workspaces_root: String::new(), claude_path: String::new(), + agy_path: String::new(), + experimental_agents: Vec::new(), start_with_windows: true, keep_awake: true, log_level: "info".to_owned(), diff --git a/crates/botloftd/src/context/mod.rs b/crates/botloftd/src/context/mod.rs index 7b307e01..50284995 100644 --- a/crates/botloftd/src/context/mod.rs +++ b/crates/botloftd/src/context/mod.rs @@ -95,6 +95,17 @@ fn update(daemon: &Daemon, bot: &BotId, change: impl FnOnce(&mut Entry)) { /// Asks the bot's process how full its conversation is. The answer comes /// as a `control_response` ([`answered`]). pub(crate) fn ask(daemon: &Daemon, bot: &BotId) { + // An agent that does not answer must not be asked. + let speaks = daemon + .store() + .bot(bot) + .ok() + .flatten() + .and_then(|record| crate::agent::of(record.agent)) + .is_some_and(|agent| agent.speaks_control()); + if !speaks { + return; + } let _ = daemon .supervisor .write_control(bot, control::context_request()); @@ -266,6 +277,12 @@ pub(crate) fn process_ended(daemon: &Daemon, bot: &BotId) { pub fn compact(daemon: &Daemon, params: BotIdParams) -> ApiResult { let store = daemon.store(); let (crew, record) = active(&store, ¶ms.bot_id)?; + if !crate::agent::of(record.agent).is_some_and(|agent| agent.speaks_control()) { + return Err(ApiError::validation(format!( + "bots that run on {} cannot compact their conversation", + record.agent.as_str() + ))); + } if !daemon.contexts.compacting(&record.id) { let not_running = || ApiError::Conflict(format!("bot {} is not running", record.id)); let ready = matches!( diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index 31929977..4bc568e4 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -40,6 +40,12 @@ pub fn create(daemon: &Daemon, params: BotsCreateParams) -> ApiResult { params.model, params.agent, )?; + if !daemon.supervisor.agent_enabled(new.agent) { + return Err(ApiError::validation(format!( + "bots that run on {} are not available yet", + new.agent.as_str() + ))); + } let store = daemon.store(); let crew = crews::active(&store, ¶ms.crew_id)?; let record = insert(daemon, &store, &crew, BotId::generate(), new)?; @@ -68,19 +74,12 @@ impl NewBot { model: Option, agent: Option, ) -> ApiResult { - let agent = agent.unwrap_or(AgentKind::Claude); - if !agent.runs() { - return Err(ApiError::validation(format!( - "bots that run on {} are not available yet", - agent.as_str() - ))); - } Ok(Self { name: validate::name("name", name)?, role: validate::role(role)?, instructions: validate::instructions(instructions)?, color: color.as_deref().map(parse_color).transpose()?, - agent, + agent: agent.unwrap_or(AgentKind::Claude), model: model.unwrap_or(BotModel::Default), effort: BotEffort::Default, }) diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index bba3388c..6505bb09 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -131,6 +131,27 @@ impl Supervisor { self.busy.subscribe() } + /// Whether bots of `kind` may be made: Claude Code always, an + /// experimental agent only when the owner listed it in `config.toml` + /// (spec 30); Codex does not run yet. + pub fn agent_enabled(&self, kind: botloft_core::protocol::AgentKind) -> bool { + use botloft_core::protocol::AgentKind; + match kind { + AgentKind::Claude => true, + AgentKind::Agy => self + .settings + .experimental_agents + .iter() + .any(|name| name == "agy"), + AgentKind::Codex => false, + } + } + + /// The `agy_path` of the config; empty looks in the usual places. + pub fn agy_path(&self) -> &str { + &self.settings.agy_path + } + /// The Claude Code executable the bots run, once found. pub fn claude_path(&self) -> Option { match &self.lock().claude { diff --git a/crates/botloftd/src/supervisor/settings.rs b/crates/botloftd/src/supervisor/settings.rs index 4422e38c..c78d6077 100644 --- a/crates/botloftd/src/supervisor/settings.rs +++ b/crates/botloftd/src/supervisor/settings.rs @@ -16,6 +16,10 @@ pub enum ClaudeSource { #[derive(Debug, Clone)] pub struct SupervisorSettings { pub claude: ClaudeSource, + /// `agy_path` from the config; empty looks in the usual places (spec 30). + pub agy_path: String, + /// Agents the owner switched on although they are experimental. + pub experimental_agents: Vec, pub backoff_initial: Duration, pub backoff_max: Duration, pub fresh_start_if_dies_within: Duration, @@ -30,6 +34,8 @@ impl SupervisorSettings { claude: ClaudeSource::Discover { configured: config.claude_path.clone(), }, + agy_path: config.agy_path.clone(), + experimental_agents: config.experimental_agents.clone(), backoff_initial: Duration::from_millis(config.supervisor.restart_backoff_initial_ms), backoff_max: Duration::from_millis(config.supervisor.restart_backoff_max_ms), fresh_start_if_dies_within: Duration::from_secs( diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index f64b4cc5..821b20bc 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -13,7 +13,7 @@ use tracing::debug; use super::slot::StopIntent; use super::{Inner, STABLE_AFTER, Supervisor}; -use crate::agent::{self, LaunchPlan}; +use crate::agent::{self, LaunchFiles, LaunchPlan}; use crate::chat::{LIVE_TEXT_EVERY, StreamReader}; use crate::platform; use crate::runtime::claude::Claude; @@ -116,6 +116,7 @@ pub(super) fn launch_spec( let agent = agent::of(bot.agent).ok_or_else(|| { io::Error::other(format!("bots on {} cannot start yet", bot.agent.as_str())) })?; + let program = agent.locate(daemon.supervisor.agy_path(), &claude.path)?; let mcp = workspace.join(".botloft").join("mcp.json"); let work_folder = daemon.paths.work_folder(crew); let args = agent.args(&LaunchPlan { @@ -142,14 +143,26 @@ pub(super) fn launch_spec( ] { env.push((OsString::from(name), value)); } - env.extend(agent.extra_env()); + // What the agent sets replaces what the user environment has. + for (name, value) in agent.extra_env(&workspace) { + env.retain(|(have, _)| !have.eq_ignore_ascii_case(&name)); + env.push((name, value)); + } + let mut fenced = workspace::fences(&daemon.paths, crew, &crews); + fenced.push(daemon.paths.secrets()); + agent.write_launch_files(&LaunchFiles { + workspace: &workspace, + port: daemon.port, + token: &token, + fenced: &fenced, + })?; // The values of the headers and variables of the bot's connected tools, // which its `mcp.json` expands (spec 25.2). env.extend(mcp_secrets::environment(&daemon.paths.secrets(), &servers)?); let spec = SpawnSpec { - program: claude.path.clone(), + program, args, cwd: workspace.clone(), env, diff --git a/crates/botloftd/src/supervisor/start.rs b/crates/botloftd/src/supervisor/start.rs index d2254e70..cb42a200 100644 --- a/crates/botloftd/src/supervisor/start.rs +++ b/crates/botloftd/src/supervisor/start.rs @@ -123,12 +123,16 @@ impl Supervisor { .fetch_add(1, std::sync::atomic::Ordering::Relaxed); // What the session applies and how full it is (spec 9.2); Claude // Code answers as soon as it is up. - for ask in [control::settings_request(), control::context_request()] { - let _ = process.control.write(ask); + // The agent of a launched bot runs. + let speaks = agent::of(bot.agent).is_some_and(|agent| agent.speaks_control()); + if speaks { + for ask in [control::settings_request(), control::context_request()] { + let _ = process.control.write(ask); + } } // Each connected tool says whether it came up (spec 25.5). crate::service::mcp_state::forget(daemon, &bot.id); - if launch.connected { + if launch.connected && speaks { let _ = process.control.write(control::mcp_request()); } context::process_started(daemon, &bot.id, launch.resumed); diff --git a/crates/botloftd/src/supervisor/turns.rs b/crates/botloftd/src/supervisor/turns.rs index 2f8049e7..713b3b57 100644 --- a/crates/botloftd/src/supervisor/turns.rs +++ b/crates/botloftd/src/supervisor/turns.rs @@ -64,6 +64,24 @@ impl Supervisor { }); } + /// The agent took up a message without giving its id back (`agy` does + /// not): it was the oldest one written. Returns that id so its delivery + /// can be marked read. + pub(crate) fn oldest_message_began(&self, bot: &BotId, generation: u64) -> Option { + let mut inner = self.lock(); + let Inner { slots, .. } = &mut *inner; + let slot = slots.get_mut(bot)?; + if slot.generation != Some(generation) || slot.running.is_none() { + return None; + } + let uuid = (!slot.waiting.is_empty()).then(|| slot.waiting.remove(0)); + slot.in_turn = true; + if slot.is_working() { + self.set_state(bot, slot, slot.working_state()); + } + uuid + } + /// Claude Code lists the subagents it runs in the background (`system/ /// background_tasks_changed`); the whole list comes each time. They work /// after the turn that started them has ended. diff --git a/crates/botloftd/tests/agy_bot.rs b/crates/botloftd/tests/agy_bot.rs new file mode 100644 index 00000000..742eb9c4 --- /dev/null +++ b/crates/botloftd/tests/agy_bot.rs @@ -0,0 +1,337 @@ +//! A bot that runs on Antigravity's `agy` (spec 30), on a FakeRuntime: its +//! process prints `agy`'s stream-json, and the chat, the read receipt, the +//! conversation to resume and the files `agy` reads come out as for Claude. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{ + AgentKind, BotState, BotsCreateParams, ChatBody, CrewsCreateParams, ToolStatus, +}; +use botloftd::runtime::fake::FakeProcess; +use botloftd::service::{bots, crews}; +use botloftd::state::Daemon; +use botloftd::supervisor; +use common::{new_daemon, test_settings}; +use serde_json::{Value, json}; + +struct Agy { + daemon: Arc, + runtime: botloftd::runtime::fake::FakeRuntime, + bot: BotId, + paths: botloftd::paths::Paths, + _dir: tempfile::TempDir, +} + +async fn agy_bot() -> Agy { + let mut settings = test_settings(); + settings.experimental_agents = vec!["agy".to_owned()]; + settings.agy_path = r"C:\Agy\agy.exe".to_owned(); + let parts = new_daemon(settings); + let crew = crews::create( + &parts.daemon, + CrewsCreateParams { + name: "Ops".into(), + work_folder: None, + lead: None, + }, + ) + .expect("crew"); + let bot = bots::create( + &parts.daemon, + BotsCreateParams { + crew_id: crew.id, + name: "Scout".into(), + role: String::new(), + instructions: "Keep notes.".into(), + color: None, + model: None, + agent: Some(AgentKind::Agy), + }, + ) + .expect("agy bot"); + assert_eq!(bot.agent, AgentKind::Agy); + tokio::spawn(supervisor::run(Arc::clone(&parts.daemon))); + Agy { + daemon: parts.daemon, + runtime: parts.runtime, + bot: bot.id, + paths: parts.paths, + _dir: parts.dir, + } +} + +impl Agy { + async fn until(&self, state: BotState) { + for _ in 0..600 { + if self.daemon.supervisor.status(&self.bot).map(|(s, _)| s) == Some(state) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("bot never reached {state:?}"); + } + + fn items(&self) -> Vec { + let mut items = self + .daemon + .store() + .chat_history(&self.bot, None, 50) + .expect("history"); + items.reverse(); + items.into_iter().map(|item| item.body).collect() + } +} + +fn step(conversation: &str, index: u32, kind: &str, state: &str, extra: Value) -> Value { + let mut update = json!({ + "conversation_id": conversation, + "step_index": index, + "step_type": kind, + "state": state, + }); + for (key, value) in extra.as_object().expect("object") { + update[key] = value.clone(); + } + json!({ "event": "step_update", "step_update": update }) +} + +#[tokio::test(start_paused = true)] +async fn an_agy_bot_starts_without_a_session_and_with_its_own_home() { + let agy = agy_bot().await; + let process = agy.runtime.process(1).await; + let args = process.args(); + assert!(args.contains(&"stream-json".to_owned())); + assert!(!args.contains(&"--conversation".to_owned()), "{args:?}"); + assert_eq!(args.last().map(String::as_str), Some("-p=")); + // `agy` does not answer Botloft's own requests, and refuses their format. + assert!(process.control_requests().is_empty()); + assert!(process.input().is_empty()); + assert_eq!(process.spec.program.to_string_lossy(), r"C:\Agy\agy.exe"); + + // Its home, where `agy` reads its own settings and MCP servers. + let workspace = process.spec.cwd.clone(); + let home = workspace.join(".botloft").join("agy-home"); + assert_eq!(process.env("USERPROFILE").as_deref(), home.to_str()); + let mcp: Value = serde_json::from_str( + &std::fs::read_to_string(home.join(".gemini/config/mcp_config.json")).expect("mcp"), + ) + .expect("json"); + assert_eq!( + mcp["mcpServers"]["botloft"]["serverUrl"], + "http://127.0.0.1:45710/mcp" + ); + assert!( + mcp["mcpServers"]["botloft"]["headers"]["Authorization"] + .as_str() + .is_some_and(|value| value.starts_with("Bearer ")) + ); + let settings: Value = serde_json::from_str( + &std::fs::read_to_string(home.join(".gemini/antigravity-cli/settings.json")) + .expect("settings"), + ) + .expect("json"); + assert_eq!(settings["permissions"]["allow"][0], "mcp(botloft/*)"); + let secrets = agy.paths.secrets().to_string_lossy().replace('\\', "/"); + let deny = settings["permissions"]["deny"].as_array().expect("deny"); + assert!(deny.contains(&json!(format!("write_file({secrets})")))); + + // The rules the bot reads, in the file `agy` loads from its folder. + let rules = std::fs::read_to_string(workspace.join("AGENTS.md")).expect("AGENTS.md"); + assert!( + rules.contains("Scout") && rules.contains("Keep notes."), + "{rules}" + ); +} + +#[tokio::test(start_paused = true)] +async fn what_agy_prints_becomes_the_chat() { + let agy = agy_bot().await; + let process: FakeProcess = agy.runtime.process(1).await; + agy.until(BotState::Idle).await; + + // The courier writes a message, tracked by its uuid until `agy` takes it. + agy.daemon + .supervisor + .write_message(&agy.bot, "u-1", "{}\n".into()) + .expect("running"); + agy.until(BotState::Busy).await; + + let conv = "c-1"; + process + .emit(json!({ "event": "init", "init": { "conversation_id": conv, "model": "gemini-3.8-flash-low" } })) + .await; + process + .emit(step(conv, 0, "user_input", "DONE", json!({}))) + .await; + process + .emit(step( + conv, + 1, + "agent_response", + "ACTIVE", + json!({ "text_delta": "On it." }), + )) + .await; + process + .emit(step( + conv, + 1, + "agent_response", + "DONE", + json!({ "text_delta": "\n" }), + )) + .await; + process + .emit(step( + conv, + 2, + "tool", + "ACTIVE", + json!({ "tool_name": "run_command", "tool_info": { "parameters": { "CommandLine": "echo hi" } } }), + )) + .await; + process + .emit(step( + conv, + 2, + "tool", + "ERROR", + json!({ "tool_name": "run_command", "tool_info": { "error": { "message": "denied" } } }), + )) + .await; + process + .emit(step( + conv, + 3, + "agent_response", + "ACTIVE", + json!({ "text_delta": "Done." }), + )) + .await; + process + .emit(step( + conv, + 3, + "agent_response", + "DONE", + json!({ "text_delta": "\n" }), + )) + .await; + process + .emit(json!({ "event": "result", "result": { + "conversation_id": conv, "status": "SUCCESS", "response": "Done.\n", + "duration_seconds": 2.5, "num_turns": 1, + "usage": { "input_tokens": 100, "output_tokens": 7, "thinking_tokens": 3, "cache_read_tokens": 40, "total_tokens": 150 } + } })) + .await; + agy.until(BotState::Idle).await; + + let items = agy.items(); + let replies: Vec<&str> = items + .iter() + .filter_map(|body| match body { + ChatBody::Reply(reply) => Some(reply.text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(replies, ["On it.", "Done."]); + let tool = items + .iter() + .find_map(|body| match body { + ChatBody::Tool(tool) => Some(tool), + _ => None, + }) + .expect("a tool"); + assert_eq!(tool.name, "Bash"); + assert_eq!(tool.status, ToolStatus::Failed); + assert_eq!(tool.output.as_deref(), Some("denied")); + let turn = items + .iter() + .find_map(|body| match body { + ChatBody::Turn(turn) => Some(turn), + _ => None, + }) + .expect("a turn"); + assert_eq!(turn.duration_ms, 2500); + assert_eq!(turn.error, None); + let tokens = turn.tokens.as_ref().expect("tokens"); + assert_eq!( + (tokens.input, tokens.cache_read, tokens.output), + (100, 40, 10) + ); + + // `agy` has the conversation from its first message on: the next start + // resumes it. + assert_eq!( + agy.daemon + .store() + .session_id(&agy.bot) + .expect("read") + .as_deref(), + Some(conv) + ); + let bot = agy + .daemon + .store() + .bot(&agy.bot) + .expect("read") + .expect("bot"); + assert_eq!(bot.model_in_use.as_deref(), Some("gemini-3.8-flash-low")); +} + +#[tokio::test(start_paused = true)] +async fn a_failed_turn_says_why() { + let agy = agy_bot().await; + let process = agy.runtime.process(1).await; + agy.until(BotState::Idle).await; + agy.daemon + .supervisor + .write_message(&agy.bot, "u-1", "{}\n".into()) + .expect("running"); + process + .emit(step("c-2", 0, "user_input", "DONE", json!({}))) + .await; + process + .emit(json!({ "event": "result", "result": { + "conversation_id": "c-2", "status": "ERROR", "response": "", "error": "model overloaded", + "duration_seconds": 1.0, "num_turns": 0, "usage": {} + } })) + .await; + agy.until(BotState::Idle).await; + let failed = agy.items().into_iter().find_map(|body| match body { + ChatBody::Turn(turn) => turn.error, + _ => None, + }); + assert_eq!(failed.as_deref(), Some("model overloaded")); +} + +#[tokio::test(start_paused = true)] +async fn the_next_start_resumes_the_conversation() { + let agy = agy_bot().await; + let first = agy.runtime.process(1).await; + agy.until(BotState::Idle).await; + agy.daemon + .supervisor + .write_message(&agy.bot, "u-1", "{}\n".into()) + .expect("running"); + first + .emit(step("c-3", 0, "user_input", "DONE", json!({}))) + .await; + first + .emit(json!({ "event": "result", "result": { "status": "SUCCESS", "duration_seconds": 0.1, "usage": {} } })) + .await; + agy.until(BotState::Idle).await; + + first.exit(1).await; + let second = agy.runtime.process(2).await; + let args = second.args(); + let at = args + .iter() + .position(|a| a == "--conversation") + .expect("resumed"); + assert_eq!(args[at + 1], "c-3"); +} diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index ed7abf78..2fed4e83 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -67,6 +67,8 @@ pub fn test_settings() -> SupervisorSettings { path: PathBuf::from(r"C:\Claude\claude.exe"), version: "2.1.284".to_owned(), }), + agy_path: String::new(), + experimental_agents: Vec::new(), backoff_initial: Duration::from_millis(40), backoff_max: Duration::from_millis(200), fresh_start_if_dies_within: Duration::from_secs(15), diff --git a/docs/spec.md b/docs/spec.md index 5d7329ae..60f42d13 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -144,6 +144,8 @@ Identidade e instruções vão em `.claude/rules/botloft.md` e não na linha de port = 45710 # só 127.0.0.1 no MVP workspaces_root = "" # vazio = %USERPROFILE%\Botloft claude_path = "" # vazio = resolver pelo PATH +agy_path = "" # vazio = %LOCALAPPDATA%\agy\bin e o PATH (seção 30) +experimental_agents = [] # ["agy"] liga os bots Antigravity (seção 30) start_with_windows = true # o daemon sobe quando o dono entra no Windows (14) keep_awake = true # impede suspensão enquanto houver bot busy log_level = "info" @@ -2644,7 +2646,7 @@ Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot. |---|---|---| | **A0** | ADR, esta seção, `bots.agent` no banco e no protocolo; `bots.create` com `codex` ou `agy` dá `-32004` ("not available yet") | feito | | **A1** | A interface `Agent` no daemon (`crates/botloftd/src/agent`), com o Claude por trás e os testes de hoje sem mudança. Em fatias: **A1a** argumentos do processo, ambiente extra e codificação do turno no stdin (feito); **A1b** leitura da saída: cada agente decodifica o próprio fluxo (`Agent::handle_output`, `Agent::live_text`) e grava no chat por `chat::sink` (resposta, ferramenta que começa e termina, fim de turno), o que todo agente tem; o que é só do Claude (contexto, compactação, parte do plano, rascunho de telas) fica no decodificador dele, `chat/claude.rs` (feito); **A1c** descoberta e login, arquivos da pasta do bot e aprovações | em andamento | -| **A2** | AGY: lançamento, entrada e saída, MCP, aprovações, isolamento, modelos e esforço, testados com o `agy` real | a fazer | +| **A2** | AGY, **experimental**: só existe quando `experimental_agents = ["agy"]` no `config.toml`. Lançamento, entrada e saída, MCP do Botloft, isolamento por regras `deny`, retomada de conversa, no `agy` real. Sem aprovações do dono e sem comandos de shell (30.3); sem modelo e esforço escolhidos, contexto, compactação nem ferramentas conectadas do dono | feito (sem seletor no app) | | **A3** | Codex por `codex app-server` | a fazer | | **A4** | Seletor de agente no app, conta e login de cada agente, paridade de rotinas, navegador, desktop e perguntas | a fazer | @@ -2652,7 +2654,16 @@ Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot. Descoberta e saúde (binário, versão mínima, login, comando de entrar); lançamento (argumentos, ambiente, arquivos da pasta do bot: instruções, regras, configuração de MCP, isolamento); entrada (turno do dono, de outro bot ou do daemon, imagens, recibo de leitura); saída (decodificador com estado para eventos normalizados: turno começou, resposta, ferramenta, turno acabou, contexto, limite, falha de login, sessão iniciada ou perdida, subagentes, modelo em uso); aprovações (pedido no formato comum `{tool_name, input, tool_use_id}` e a resposta de volta); capacidades (modelos, esforços, modos de permissão, vocabulário de ferramentas). Modelo e esforço passam a ser texto validado pelas capacidades do agente. O que um agente não faz aparece desligado no app. -### 30.3 Isolamento +### 30.3 Antigravity (`agy`), experimental + +- **Ligar:** `experimental_agents = ["agy"]` no `config.toml`; sem isso, `bots.create` com `agent: "agy"` dá `-32004`. O app ainda não tem o seletor (A4). +- **Casa própria:** o processo roda com `USERPROFILE` e `HOME` em `\.botloft\agy-home`. Lá o Botloft escreve a cada start `.gemini/config/mcp_config.json` (o servidor `botloft`, com o token do bot no cabeçalho) e `.gemini/antigravity-cli/settings.json`. Assim o `GEMINI.md`, os servidores MCP e as configurações pessoais do dono ficam de fora (o login não mora ali e continua valendo). +- **Regras do bot:** o texto que o Claude lê em `.claude/rules/botloft.md` é copiado para `AGENTS.md` na pasta do bot, que o `agy` carrega. +- **Permissões:** em `-p` o `agy` não pergunta nada (30.4), então o Botloft decide de antemão: `allow` só `mcp(botloft/*)`; `deny` de `read_file()` e `write_file()` para cada pasta cercada (a mesma lista da seção 7.5: dados do Botloft e pastas de outras crews). Comandos de shell são negados pelo próprio `agy`, e o `result` traz `denied_actions`. O dono ainda não tem como liberar um comando. +- **Não faz:** pedidos de controle (`get_settings`, `get_context_usage`, `mcp_status`, `/compact`): o `agy` recusa o formato; por isso o medidor de contexto e "Compactar" não existem para esses bots. Também não há cartão de plano, uso do plano nem ferramentas conectadas do dono. +- **Eventos:** `init` dá o modelo; `step_update` `user_input` `DONE` é o recibo de leitura da mensagem mais antiga escrita (o `agy` não devolve o id) e grava a conversa a retomar (`--conversation`); `agent_response` `ACTIVE` é o texto ao vivo e `DONE` fecha a resposta; `tool` `ACTIVE`, `DONE` e `ERROR` são a ferramenta (`write_to_file`, `run_command`, `view_file`... aparecem como `Write`, `Bash`, `Read`); `result` fecha o turno, com `usage` e `status`. + +### 30.3.1 Isolamento dos agentes Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Claude: nada de ler as outras crews nem os dados do Botloft. Cada agente usa o seu mecanismo, e o resultado do teste real entra aqui antes de o agente ser liberado. @@ -2662,7 +2673,15 @@ Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Cl |---|---|---| | `agy` 1.3.1: modo de impressão com `stream-json` | `agy --input-format stream-json --output-format stream-json --model -p=` lê uma linha JSON por mensagem: `{"event":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}`. Sem `event` ou sem `content` responde erro explícito. O `-p` precisa do `=` quando outras flags vêm depois | feito: um turno, `gemini-3.8-flash-low` | | `agy`: eventos de saída | `init` (`conversation_id`, `model`, `cwd`, `tools`, `permission_mode` = `request-review`), `step_update` (`step_type` `user_input` ou `agent_response`, `state` `ACTIVE` ou `DONE`, `text_delta`, `usage`) e `result` (`status` `SUCCESS` ou `ERROR`, `response`, `usage` com `input_tokens`, `output_tokens`, `thinking_tokens`, `cache_read_tokens`, `total_tokens`). O processo saiu com o fim do stdin | feito: um turno | -| `agy`: vários turnos num processo, ferramentas, pedido de aprovação, MCP por HTTP, `--conversation`, isolamento por `--sandbox` e `--add-dir` | **Não visto** | a fazer (A2) | +| `agy`: vários turnos num processo | Com o stdin aberto, duas mensagens com 25 s de intervalo viram dois turnos: um `result` por turno (`num_turns` 1, depois 2), e o segundo lembra do primeiro. Os `step_index` seguem contando. O texto vem com o prefixo `[MAHI68] `, que não sabemos explicar | feito: 1.3.1, `gemini-3.8-flash-low` | +| `agy`: ferramentas | `step_type` `tool`, `state` `ACTIVE`, `DONE` ou `ERROR`, `tool_name` (`write_to_file`, `run_command`...) e `tool_info.parameters` (`TargetFile`, `CommandLine`...). Não há bloco de resultado da ferramenta como no Claude; o erro vem em `tool_info.error` | feito: um `write_to_file` e um `run_command` | +| `agy`: aprovações em modo `-p` | **Não há canal para o dono aprovar.** `init.permission_mode` vem `request-review`, mas escrever um arquivo, dentro ou **fora** da pasta de trabalho, não pediu nada. Um comando de shell (`run_command`) foi **negado sozinho**: "headless mode cannot prompt"; o `result` traz `denied_actions: [{action:"command"}]`. A mensagem manda pôr uma regra em `permissions.allow` do `settings.json` (`command()`) ou usar `--dangerously-skip-permissions` | feito: 1.3.1 | +| `agy`: isolamento | `--sandbox` **não** impediu a escrita fora da pasta (só restringe o terminal). O agente carrega `~/.gemini/GEMINI.md` do dono (memória pessoal, 24 KB aqui) e usa `~/.gemini/antigravity-cli/settings.json`; o MCP fica em `~/.gemini/config/mcp_config.json`. **`USERPROFILE` muda essa pasta** e o login sobrevive; sem o `GEMINI.md` do dono o prefixo `[MAHI68]` some. Regras `permissions` no `settings.json`: `command(echo hello)` em `allow` funcionou; `write_file(C:/pasta/completa)` em `deny` bloqueia a escrita ali (letra do drive, barras normais, sem curinga; `/**`, `/*` e o caminho sem o drive **não** casaram; `write_file(*)` bloqueia tudo). Ação e alvos conforme `antigravity.google/docs/permissions` | feito: 1.3.1. Na pasta de um daemon real, um bot não leu o `owner.token` (fechou em `DENIED`) | +| `agy`: instruções da pasta | `AGENTS.md` e `GEMINI.md` na pasta de trabalho são lidos; `.claude/rules/*.md` e `.agents/rules/*.md` **não** | feito: 1.3.1 | +| `agy`: MCP por HTTP | `agy mcp add --header "K: V" ` grava `{"mcpServers":{"":{"disabled":false,"headers":{...},"serverUrl":""}}}` em `~/.gemini/config/mcp_config.json`. **Testado com o daemon real**: o bot listou as ferramentas do servidor `botloft` (`send_message`, `complete_task`, `ask_owner`...) com `allow` `mcp(botloft/*)` | feito: 1.3.1 | +| `agy`: pedido de controle de outro formato | Uma linha `{"type":"control_request",...}` no stdin vira um turno com erro: `stream input message is missing the "event" field` (visto no daemon real antes de o Botloft parar de enviá-la) | feito: 1.3.1 | +| `agy`: `--conversation`, `--add-dir` | Passados no lançamento e cobertos por teste com o `FakeRuntime`; a retomada **não** foi vista com o `agy` real | a fazer | +| `agy`: erros de login, limite de uso | **Não visto**: um bot sem login ou no limite aparece como turno com erro | a fazer | | `agy models` | Lista `gemini-3.8-flash-{high,medium,low}`, `gemini-3.7-...`, `gemini-3.1-pro-{high,low}` e `claude-opus-5-5-{low,medium,high}`, entre outros: o nível de esforço faz parte do id do modelo | feito | | `codex` 0.148.0-alpha.9: `exec --json` | Um turno por processo: `thread.started`, `turn.started`, `item.completed` (`agent_message`), `turn.completed` com `usage` (`input_tokens`, `cached_input_tokens`, `output_tokens`, `reasoning_output_tokens`). Não serve para um bot que fica ligado | feito: um turno, `read-only` | | `codex app-server` | Experimental, JSON-RPC. `app-server generate-json-schema` gera o esquema, que traz pedidos de aprovação (`CommandExecutionRequestApprovalParams`, `FileChangeRequestApprovalParams`, `PermissionsRequestApprovalParams`, `McpServerElicitationRequestParams`) e `DynamicToolCallParams` | esquema gerado e lido; conversa **não vista** (A3) |