From d0119362158854c29a2df10d5e18d27655557276 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:39:23 -0300 Subject: [PATCH 1/2] docs(spec): what agy 1.3.1 does with tools, approvals and isolation in -p --- docs/spec.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/spec.md b/docs/spec.md index 5d7329ae..bc8ef430 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2662,7 +2662,11 @@ 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` (`trustedWorkspaces`, modelo); o MCP fica em `~/.gemini/config/mcp_config.json`. Não achei no binário variável que mude essa pasta. As regras `permissions.deny` não foram testadas | feito: 1.3.1; **bloqueia o A2** (decisão no ADR 0003) | +| `agy`: MCP por HTTP, `--conversation`, `--add-dir` | **Não visto** | a fazer (A2) | | `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) | From f35ee54c8206d2b676593dea2e110f27b5e5551b Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:24:47 -0300 Subject: [PATCH 2/2] feat(agents): Antigravity (agy) bots, experimental (A2 of spec 30) An agy bot runs with its own home as USERPROFILE (MCP config with the bot's token, settings with deny rules for the fenced folders, no personal GEMINI.md), reads AGENTS.md for its rules, and its stream-json becomes the chat through chat::sink. Off unless experimental_agents lists agy. Agent gains locate, extra_env, write_launch_files, speaks_control and a per-process OutputDecoder. Checked with the real agy 1.3.1 in a dev daemon: the bot used the Botloft MCP tools and could not read owner.token. --- crates/botloft-core/src/protocol/model.rs | 7 - crates/botloftd/src/agent/agy.rs | 429 +++++++++++++++++++++ crates/botloftd/src/agent/claude.rs | 34 +- crates/botloftd/src/agent/mod.rs | 76 +++- crates/botloftd/src/chat/claude.rs | 22 +- crates/botloftd/src/chat/mod.rs | 12 +- crates/botloftd/src/chat/session.rs | 11 +- crates/botloftd/src/chat/sink.rs | 42 +- crates/botloftd/src/config.rs | 7 + crates/botloftd/src/context/mod.rs | 17 + crates/botloftd/src/service/bots.rs | 15 +- crates/botloftd/src/supervisor/mod.rs | 21 + crates/botloftd/src/supervisor/settings.rs | 6 + crates/botloftd/src/supervisor/spawn.rs | 19 +- crates/botloftd/src/supervisor/start.rs | 10 +- crates/botloftd/src/supervisor/turns.rs | 18 + crates/botloftd/tests/agy_bot.rs | 337 ++++++++++++++++ crates/botloftd/tests/common/mod.rs | 2 + docs/spec.md | 23 +- 19 files changed, 1024 insertions(+), 84 deletions(-) create mode 100644 crates/botloftd/src/agent/agy.rs create mode 100644 crates/botloftd/tests/agy_bot.rs 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 bc8ef430..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. @@ -2665,8 +2676,12 @@ Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Cl | `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` (`trustedWorkspaces`, modelo); o MCP fica em `~/.gemini/config/mcp_config.json`. Não achei no binário variável que mude essa pasta. As regras `permissions.deny` não foram testadas | feito: 1.3.1; **bloqueia o A2** (decisão no ADR 0003) | -| `agy`: MCP por HTTP, `--conversation`, `--add-dir` | **Não visto** | a fazer (A2) | +| `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) |